Merge coracle upstream/dev into email-notifications

Brings the email-notifications feature (email digest settings UI + mail
adapter) onto the current coracle dev, resolving divergences with
upstream's newer notification-settings work:

- alerts page: upstream's saveNotificationSettings flow persists
  push/sound/badge + event reminders (re-arming syncEventReminders) and the
  feature's email block (Mail.register/enable + AppData email prefs) is
  preserved, mirroring prefs into the app-level store the push adapters read.
  Report-reminder UI wiring restored; single success toast.
- settings.ts: keeps feature's notificationSettings store + email salt,
  adopts upstream's updateSpaceAlert helpers + settingsFilter.
- +layout.svelte: Mail.sync() retained; signer alerts adopt upstream's
  syncSignerAlerts refactor.
- env.ts/Dockerfile: EMAIL_NOTIFICATION_SERVER + upstream's strict-equality
  fix and scripts/build/app.sh pipeline.
This commit is contained in:
Agent 2026-10-01 17:12:42 -04:00
commit 91c17b5e0c
589 changed files with 15829 additions and 15833 deletions

View file

@ -20,9 +20,8 @@ and UI.
| App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other | | App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other |
| Lib | `src/lib` | `@lib/x` | external packages only | | Lib | `src/lib` | `@lib/x` | external packages only |
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`. `svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`, not
SvelteKit's built-in `$lib` also resolves, but only four stray imports use it (in `relays.ts`, SvelteKit's built-in `$lib`, which also resolves. There is no barrel file, so import each
`callEngine.ts` and `VoiceRoomJoinDialog.svelte`). There is no barrel file, so import each
component by its path (`@lib/components/Button.svelte`), not from `$lib/components` as the component by its path (`@lib/components/Button.svelte`), not from `$lib/components` as the
AGENTS.md example has it. Icons come from `@assets/icons/<name>.svg?dataurl`. AGENTS.md example has it. Icons come from `@assets/icons/<name>.svg?dataurl`.
@ -44,13 +43,16 @@ and `sync.ts`.
No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the
only gate. only gate.
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`, and `ImageInputButton.svelte` - **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib
and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app component that needs app behavior takes it as a prop, or moves to `src/app/components`, as
behavior takes it as a prop, or moves to `src/app/components`. `IconInput` and its picker buttons did.
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`, - **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
`ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal `NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`), `speech.ts`
mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor` (`OpenRouterEnable`) and `healthChecks.ts` (`KeyDownload`, the fix for the key backup check)
mounts. import a component so they can open a modal mid-flow. Push adapters and
deep links reach those flows from outside any component, and the rest have several component
callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion
popovers), which `makeEditor` mounts.
- Nothing under `src/app` or `src/lib` imports from `src/routes`. - Nothing under `src/app` or `src/lib` imports from `src/routes`.
## Top-level layout ## Top-level layout
@ -71,7 +73,9 @@ only gate.
## `src/app` by concern ## `src/app` by concern
`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions. `src/app/components` is flat except for `hosting/`. It also holds the CSS families for app
concepts (`chat.css`, `room.css`, `role-badge.css`, `space-menu.css`), which `src/app/app.css`
imports. `flotilla-views` covers component conventions.
**Core and session** **Core and session**
- `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts - `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
@ -81,8 +85,8 @@ only gate.
cache cache
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs - `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings - `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
- `repository.ts`: `derive*` helpers over the current app's repository - `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event
- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking) - `publications.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
- `env.ts`: every `VITE_` value, parsed - `env.ts`: every `VITE_` value, parsed
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a - `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
device id) device id)
@ -95,7 +99,7 @@ only gate.
node views node views
**Spaces, rooms and administration** **Spaces, rooms and administration**
- `relays.ts`: relay URL encoding for routes, socket status, LiveKit detection - `relays.ts`: relay URL encoding for routes, socket status, the LiveKit endpoint and detection
- `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces - `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces
- `access.ts`: joining, invites, relay auth errors - `access.ts`: joining, invites, relay auth errors
- `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles) - `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles)
@ -105,24 +109,28 @@ only gate.
- `hosting.ts`: client for the hosting backend's HTTP API - `hosting.ts`: client for the hosting backend's HTTP API
**Content** **Content**
- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`) and comment/delete - `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`), comment/delete
filters filters, and `partitionByActivity`, which the list pages use to sort a kind by latest comment
- `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed` - `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed`
- `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts` - `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts`
- `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as - `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom) text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox) - `notifications.ts` (unread state, badges, the home inbox)
**Messaging and calls** **Messaging and calls**
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices) - `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, the video
tiles a call shows, and the `AbortError`/`TimeoutError` a join rejects with). `call.ts` stays
free of livekit values, since the root layout loads it and `callEngine.ts` is imported lazily.
**Identity and payments** **Identity and payments**
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts` - `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
(prompts for missing inbox/outbox relays) (prompts for missing inbox/outbox relays)
**Platform and voice** **Platform and voice**
- `push/` (notification adapters), `share.ts`, `keyboard.ts` - `push/` (notification adapters, and `server.ts` for the push server's HTTP API), `share.ts`,
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter `deepLinks.ts`, `keyboard.ts`
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through `postOpenRouter` in
`openrouter.ts`
A feature gets a `src/app/<feature>.ts` only when it has non-UI logic to hold. Polls, goals, A feature gets a `src/app/<feature>.ts` only when it has non-UI logic to hold. Polls, goals,
threads and calendar events have no module; their components use domain readers directly. threads and calendar events have no module; their components use domain readers directly.
@ -133,29 +141,34 @@ Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman,
env, or the `App` instance. A good test is whether it would work unchanged in another nostr env, or the `App` instance. A good test is whether it would work unchanged in another nostr
client. client.
- `util.ts`: small helpers (`errorMessage`, `AbortError`/`TimeoutError`, `buildUrl`, - `util.ts`: small helpers (`errorMessage`, `buildUrl`, `normalizeTopic`)
`normalizeTopic`) - `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`, `compressFile`
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on - `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to - `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
`@app/feeds`, which loads events. `@app/feeds`, which loads events.
- `livekit.ts`: finds a relay's LiveKit endpoint - `audio.ts` (device lists, a live input level, WAV encoding), `tileGrid.ts` (the call grid's
geometry)
- `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next) - `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next)
- `test/`: the DEV-only hooks the e2e harness injects through - `test/`: the DEV-only hooks the e2e harness injects through
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`) - `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which
`src/app/app.css` imports ahead of the app's own CSS families
## Boot sequence ## Boot sequence
`src/routes/+layout.svelte` runs the boot sequence. It imports `@app/policies` for its side effect, `src/routes/+layout.svelte` runs the boot sequence. It imports `@app/policies` for its side effect,
and `@app/storage`, which registers `storagePolicy` the same way, so every `AppPolicy` is on and `@app/storage`, which registers `storagePolicy` the same way, so every `AppPolicy` is on
`appPolicies` before anything calls `app.get()`. Then, in order: `appPolicies` before anything calls `app.get()`. `applySavedTheme()` stamps the last theme and font
size from `localStorage` before first paint. Then, in order:
1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped 1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped
`App` through `login`. `App` through `login`.
2. The device, wallet and notification stores sync to `kv`/`ss`. 2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification
3. It waits for storage, then handles a cold-start deep link. settings, `shouldUnwrap`, `forceHealthChecks`).
3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a
cold-start one.
4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`, 4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`,
`syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`. `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`,
`syncSignerAlerts`, and `syncTheme`, which mirrors the theme stores back to `localStorage`.
When login swaps in a new `App`, the layout runs `syncApplicationData` again. Routes render inside When login swaps in a new `App`, the layout runs `syncApplicationData` again. Routes render inside
`AppContainer`, behind the login gate, and `ModalContainer` renders outside it. `flotilla-state` `AppContainer`, behind the login gate, and `ModalContainer` renders outside it. `flotilla-state`
@ -167,10 +180,10 @@ One web build runs in several shells:
- **Web/PWA.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest, - **Web/PWA.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest,
except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients. except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients.
- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build.sh` runs the - **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build/app.sh` runs the
web build, `cap sync`, and native asset generation. web build, `cap sync`, and native asset generation.
- **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by - **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by
`scripts/build-desktop.sh` and `scripts/dev-desktop.mjs`. `scripts/desktop/build.sh` and `scripts/desktop/dev.mjs`.
- **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it - **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it
rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's
`Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and `Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and
@ -179,7 +192,7 @@ One web build runs in several shells:
Platform checks call Capacitor directly. There is no wrapper module: Platform checks call Capacitor directly. There is no wrapper module:
```ts ```ts
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" // src/app/env.ts export const ENABLE_ZAPS = Capacitor.getPlatform() !== "ios" // src/app/env.ts
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts
if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts
@ -195,7 +208,8 @@ Flotilla's own native code:
without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with
`registerPlugin` (`push/adapters/android.ts`, `share.ts`). `registerPlugin` (`push/adapters/android.ts`, `share.ts`).
- `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a - `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a
`flotilla://share` URL. `handleDeepLink` in the root layout passes that to `shareFromNative`. `flotilla://share` URL. `handleDeepLink` in `src/app/deepLinks.ts` passes that to
`shareFromNative`.
`Push` in `src/app/push/index.ts` chooses an adapter at runtime: the Android fallback, Capacitor `Push` in `src/app/push/index.ts` chooses an adapter at runtime: the Android fallback, Capacitor
`PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications. `PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications.
@ -210,7 +224,7 @@ guards.
- `.env` is committed and holds working defaults. `.env.local` (gitignored) overrides it. There is - `.env` is committed and holds working defaults. `.env.local` (gitignored) overrides it. There is
no `.env.template`, though AGENTS.md and the README refer to one. no `.env.template`, though AGENTS.md and the README refer to one.
- Env is read at build time. `scripts/build-web.sh` sources `.env` without overwriting variables - Env is read at build time. `scripts/build/web.sh` sources `.env` without overwriting variables
already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from
`src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and `src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and
`VITE_PLATFORM_DESCRIPTION` at runtime. `VITE_PLATFORM_DESCRIPTION` at runtime.
@ -309,7 +323,7 @@ search and the space nav entry. The kind also appears in `CONTENT_NOUNS`, the ki
`NIP46_PERMS` leaves out polls, articles and goals, so listing a new kind there is optional. `NIP46_PERMS` leaves out polls, articles and goals, so listing a new kind there is optional.
**App module.** `src/app/classifieds.ts` holds the listing logic the page would otherwise inline **App module.** `src/app/classifieds.ts` holds the listing logic the page would otherwise inline
(`partitionListings`, `deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a (`deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a
small function over a domain reader: small function over a domain reader:
```ts ```ts
@ -319,15 +333,16 @@ export const getStatus = (event: TrustedEvent) => reader(Classified)(event).stat
**Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and **Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and
`ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open `ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open
`ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the `ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the
new listing into the room. `ClassifiedActions` opens `ClassifiedEdit`. `ClassifiedItem` is the new listing into the room. Its `ContentActions` row opens `ClassifiedEdit` through `editForm`. `ClassifiedItem` is the
card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used. card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used.
**Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments **Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments
with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+page.svelte` reads with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+page.svelte` reads
one listing with `deriveEvent(address, [url])`. one listing with `$events.one(address, [url]).$`.
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
`makeArticleCreatePath`) instead of in a modal. `makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and
renders `ArticleForm`.
## Related skills ## Related skills
@ -335,7 +350,7 @@ Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
- `flotilla-views`: routes and layouts, components, modals, loading data from components - `flotilla-views`: routes and layouts, components, modals, loading data from components
- `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing - `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
- `welshman`: overview of the packages - `welshman`: overview of the packages
- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks - `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and the `Publisher`
- `welshman-domain`: readers, writers, and adding a kind - `welshman-domain`: readers, writers, and adding a kind
- `welshman-net`: the pool, sockets and socket policies - `welshman-net`: the pool, sockets and socket policies
- `welshman-util`: kind constants, tag specs, `RelaySelection` - `welshman-util`: kind constants, tag specs, `RelaySelection`

View file

@ -31,7 +31,8 @@ carries `["h", h]`; content with no `h` belongs to the whole space. `/spaces/[re
`deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list", `deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list",
which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte` which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte`
prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS` prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS`
uses `PLATFORM_RELAYS` in place of the list for sync and navigation. uses `PLATFORM_RELAYS` in place of the list for sync and navigation: `activeSpaceUrls` in
`src/app/rooms.ts` is that choice, read by background sync, notifications and editor suggestions.
### NIP-11 relay info ### NIP-11 relay info
@ -40,7 +41,7 @@ domain `Relay`. These fields drive protocol decisions:
| Read | Decides | | Read | Decides |
|---|---| |---|---|
| `hasNip(29)` | whether the space has rooms. Without it everything lives in the space chat: `makeSpaceEntryPath` (`src/app/routes.ts`), `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuRooms` | | `hasNip(29)` | whether the space has rooms, read through `spaceSupportsRooms(url)` / `deriveSpaceSupportsRooms(url)` in `src/app/relays.ts`. Without it everything lives in the space chat (`makeSpaceChatPath`): `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuNavItems`, `SpaceMenuRooms` |
| `hasNip(70)` | whether space content is marked protected (below) | | `hasNip(70)` | whether space content is marked protected (below) |
| `self` | the relay's own pubkey, the trust anchor for relay-signed state | | `self` | the relay's own pubkey, the trust anchor for relay-signed state |
| `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) | | `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) |
@ -54,8 +55,8 @@ members and pins, the space member list, and roles. Anyone can publish events of
readers must check the author. The welshman collections do: `Rooms` and every readers must check the author. The welshman collections do: `Rooms` and every
`RelaySignedDerivedPlugin` (`RelayMemberLists`, `RelayRoles`, `RoomPinLists`) drop events whose `RelaySignedDerivedPlugin` (`RelayMemberLists`, `RelayRoles`, `RoomPinLists`) drop events whose
author isn't the relay's `self`, and re-check when NIP-11 loads. For a relay-authored kind with no author isn't the relay's `self`, and re-check when NIP-11 loads. For a relay-authored kind with no
plugin, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a plugin, read `$events.relaySignedForUrl(url, filters)`, which keeps only events signed by that
bare `deriveEventsForUrl`. `self` key, rather than a bare `$events.forUrl`.
The app signs everything it publishes with the user's own key. Space-wide content with no author of The app signs everything it publishes with the user's own key. Space-wide content with no author of
its own belongs to the space's owner, the pubkey NIP-11 names: featured content its own belongs to the space's owner, the pubkey NIP-11 names: featured content
@ -104,9 +105,9 @@ user holds a method for — reports under `banevent`, join requests under `allow
- `deriveUserRoomMembershipStatus`: an admin is always `Granted`. - `deriveUserRoomMembershipStatus`: an admin is always `Granted`.
- `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing - `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing
9000, because a room member the relay won't serve can't read the room. 9000, because a room member the relay won't serve can't read the room.
- `deriveUserRooms`, `deriveOtherRooms`, `deriveOtherVoiceRooms`: rooms from the user's 10009 - `deriveUserRooms`, `deriveOtherRooms(url, type)`: rooms from the user's 10009 list and the rest
list and the rest of the space, limited to rooms the relay still advertises. `meta.hasLivekit()` of the space by `RoomType`, limited to rooms the relay still advertises. `meta.hasLivekit()`
marks a voice room. marks a voice room (`getRoomType`).
### Room flows ### Room flows
@ -119,7 +120,8 @@ user holds a method for — reports under `banevent`, join requests under `allow
- **Invite** (`publishRoomInvite`): a 9009 with a random `code` tag. The link carries `h` and - **Invite** (`publishRoomInvite`): a 9009 with a random `code` tag. The link carries `h` and
`code`, and `joinRoom(url, h, code)` sends the code as the join's `claim`. `code`, and `joinRoom(url, h, code)` sends the code as the join's `claim`.
- **Pins**: `roomPinLists.setPins(url, h, pins)` sends 9010 and the relay republishes 39005. - **Pins**: `roomPinLists.setPins(url, h, pins)` sends 9010 and the relay republishes 39005.
`deriveRoomPinnedEvents` (`src/app/roomPins.ts`) loads the pinned events from the room's relay. `toggleRoomPin(url, h, id)` (`src/app/roomPins.ts`) wraps it for the message menus and toasts
the result. `deriveRoomPinnedEvents` loads the pinned events from the room's relay.
The relay enforces the `RoomMetaReader` flags (`isClosed`, `isHidden`, `isPrivate`, The relay enforces the `RoomMetaReader` flags (`isClosed`, `isHidden`, `isPrivate`,
`isRestricted`); the UI only reflects them. Until membership is `Granted`, `RoomChat` hides a `isRestricted`); the UI only reflects them. Until membership is `Granted`, `RoomChat` hides a
@ -143,19 +145,23 @@ has no getter for them yet. Roles are only ever changed over
NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing
33534. 33534.
Joining a space (`attemptRelayAccess` and `Access` in `src/app/access.ts`): Joining a space (`attemptRelayAccess` and `completeSpaceJoin` in `src/app/access.ts`):
1. Open the socket and drive NIP-42 auth, retrying up to three times. 1. Open the socket and drive NIP-42 auth, retrying up to three times.
2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay. 2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay.
3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite. 3. Translate refusals: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code"
4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`, means rejected and "claim" means the space needs an invite.
4. `completeSpaceJoin`: opt into the space's notifications (`optInSpaceNotifications` in
`src/app/push`, which asks for push permission first when push is on), `roomLists.addRelay(url)`,
clear the relay from `relaysMostlyRestricted`, restart sync, and `Sync.push` the user's `RELAYS`,
`MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them. `MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them.
Invite links are `${PLATFORM_URL}/join?r=<relay>&c=<claim>`, plus `h` and `code` for a room Invite links are `${PLATFORM_URL}/join?r=<relay>&c=<claim>`, plus `h` and `code` for a room
(`makeInviteLink`; `parseInviteLink` also accepts a bare relay URL). `src/routes/join` renders (`makeInviteLink`; `parseInviteLink` also accepts a bare relay URL). `src/routes/join` renders
`SpaceInviteAccept`, which calls `Access.acceptInvite` to join the space and then the room. `SpaceInviteAccept`, which calls `acceptInvite` to join the space (skipped if already joined) and
`Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over
`createclaim`); this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, NIP-86 (`supportedmethods`, then `listclaims`, then `createclaim`), publishes a room invite code
for a room, and holds that state itself; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`,
`SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`. `SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`.
## NIP-86 relay management ## NIP-86 relay management
@ -166,17 +172,22 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t
| Methods | Called from | | Methods | Called from |
|---|---| |---|---|
| `supportedMethods` | `deriveSpaceSupportedMethods` (`src/app/management.ts`), `Access.prepareInvite` | | `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` | | `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` | | `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
| `listClaims`, `createClaim` | `Access.prepareInvite` | | `listClaims`, `createClaim` | `InviteLink` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control
is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every is gated on the method behind it through `deriveSpacePermissions(url)`, a store of named booleans
five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an (`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether
error from the call, since a listed method can be refused for a particular event or target. to render a menu (`memberMenu`, `roleMenu`, `bannedMenu`, `directoryMenu`) are defined there too,
so parent and menu can't disagree. The `SpaceManagement` plugin (`spaceManagement`) caches the
methods per app, re-checked at most every five minutes per URL, and `InviteLink` reads
them through `loadSupportedMethods`. It also holds the admin and ban lists, which
`loadMethodAssignees`/`loadBannedPubkeys` refresh after a change. Still handle an error from the
call, since a listed method can be refused for a particular event or target.
`deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go `deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go
on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and
`deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants. `deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants.
@ -227,10 +238,10 @@ adds `setRoom` only when it is posting into a room, as `ThreadCreate`, `Classifi
Some routing is built into welshman. `Reactions.react` and `Deletes.deleteEvent` find the target's Some routing is built into welshman. `Reactions.react` and `Deletes.deleteEvent` find the target's
relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every
space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory, space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory,
so `RoomChat` and `publishRoomQuote` publish raw templates through `Thunks`. so `RoomChat` and `publishRoomQuote` publish raw templates through `Publisher`.
Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for
a room) and read results with `deriveEventsForUrl(url, filters)`, which uses the tracker to keep a room) and read results with `$events.forUrl(url, filters)`, which uses the tracker to keep
only events seen on that relay. A repository-wide query would mix rooms that share an `h` across only events seen on that relay. A repository-wide query would mix rooms that share an `h` across
relays. `src/app/sync.ts` pulls each space's room state, membership and recent content in the relays. `src/app/sync.ts` pulls each space's room state, membership and recent content in the
background; see flotilla-state. background; see flotilla-state.
@ -289,14 +300,13 @@ These predate the rule or are waiting on welshman. Don't copy them; fix one when
- **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in - **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in
`src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`); `src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`);
`STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push `STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push
subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE` subscriptions (`PUSH_SUBSCRIPTION`, 30390, local to `src/app/push/adapters/capacitor.ts`); `DIRECT_MESSAGE_FILE`
(15) in `Chat.svelte`. (15) in `Chat.svelte`.
- **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`); - **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`);
relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in
`ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62` `ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62`
(`VANISH`). (`VANISH`).
- **Getter exists but bypassed:** titles in `src/app/title.ts`, `NoteContentThread` and the - **Getter exists but bypassed:** calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response`
threads pages; calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response`
tags in `PollVotes`; `p` tags of `RELAY_ADD_MEMBER`, `ROOM_ADD_MEMBER` and tags in `PollVotes`; `p` tags of `RELAY_ADD_MEMBER`, `ROOM_ADD_MEMBER` and
`ROOM_CREATE_PERMISSION` (`SpaceMembersSummary`, `RoomItemAddMember`, `src/app/management.ts`); `ROOM_CREATE_PERMISSION` (`SpaceMembersSummary`, `RoomItemAddMember`, `src/app/management.ts`);
comment `E`/`A` tags in the list pages and `src/app/classifieds.ts`; `imeta` unpacking in comment `E`/`A` tags in the list pages and `src/app/classifieds.ts`; `imeta` unpacking in

View file

@ -10,14 +10,14 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
|---|---|---|---|---| |---|---|---|---|---|
| Chat | `MESSAGE` (9) | none | `src/app/rooms.ts` | `spaces/[relay]/chat`, `spaces/[relay]/[h]` (`RoomChat`) | | Chat | `MESSAGE` (9) | none | `src/app/rooms.ts` | `spaces/[relay]/chat`, `spaces/[relay]/[h]` (`RoomChat`) |
| Threads | `THREAD` (11) | `Thread` | none | `spaces/[relay]/threads`, `threads/[id]` (`ThreadCreate`) | | Threads | `THREAD` (11) | `Thread` | none | `spaces/[relay]/threads`, `threads/[id]` (`ThreadCreate`) |
| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`) | `CommentCompose`, `EventReply` | | Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`), `src/app/rooms.ts` (`publishComment`) | `CommentCompose`, `EventReply`, `EventComments` |
| Articles | `LONG_FORM` (30023) | `Article` | `src/app/articles.ts` | `spaces/[relay]/articles`, `articles/create`, `articles/[address]` | | Articles | `LONG_FORM` (30023) | `Article` | `src/app/articles.ts` | `spaces/[relay]/articles`, `articles/create`, `articles/[address]` |
| Calendar | `EVENT_TIME` (31923) | `TimeEvent` | `src/app/feeds.ts` (`makeCalendarFeed`) | `spaces/[relay]/calendar`, `calendar/[address]` (`CalendarEventForm`) | | Calendar | `EVENT_TIME` (31923) | `TimeEvent` | `src/app/feeds.ts` (`makeCalendarFeed`) | `spaces/[relay]/calendar`, `calendar/[address]` (`CalendarEventForm`) |
| Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) | | Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) |
| Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) | | Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) |
| Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) | | Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) |
| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member | | Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinSave`); published by any member |
| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomPinnedMessagesAll` | | Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomItemMenuMobile`, `RoomPinnedMessagesAll` |
| Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay | | Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay |
| Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` | | Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` |
| Voice room participants | `LIVEKIT_PARTICIPANTS` (39004, defined in `src/app/call.ts`) | none | `src/app/call.ts` | rooms where `meta.hasLivekit()` | | Voice room participants | `LIVEKIT_PARTICIPANTS` (39004, defined in `src/app/call.ts`) | none | `src/app/call.ts` | rooms where `meta.hasLivekit()` |
@ -26,10 +26,10 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
| Feature | Constant (kind) | Factory | Module | Where | | Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---| |---|---|---|---|---|
| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, the `*Actions` components | | Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, `ContentActions`, `CommentActions`, `ArticleActionBar` |
| Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS | | Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS |
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` | | Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenu` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` | | Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenu` |
## Direct messages ## Direct messages
@ -52,4 +52,4 @@ Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/syn
| Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting | | Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting |
| Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` | | Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` |
| Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` | | Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` |
| Push subscriptions | 30390 (a literal, no constant) | none | `src/app/push/adapters/capacitor.ts` | background | | Push subscriptions | 30390 (`PUSH_SUBSCRIPTION`, local) | none | `src/app/push/adapters/capacitor.ts` | background |

View file

@ -0,0 +1,89 @@
---
name: flotilla-release
description: "Use this skill when cutting, publishing, or debugging a flotilla release, or when changing anything under scripts/release, scripts/fdroid, scripts/desktop, fastlane/, fdroid/, zapstore.yaml or .gitea/workflows. It covers the version bump, changelog, tagging, the local and CI release runs, every distribution target (container image, gitea release and desktop update feed, Obtainium, zapstore, Google Play, App Store, F-Droid with reproducible builds, the GitHub mirror), the credentials each needs, and a release checklist with the mistakes that have bitten before."
---
# Releasing flotilla
A release is one version tag and two runs against it. `pnpm release:local`, on a Mac, does everything that needs a signing key, so no key ever sits on the server. Pushing the tag starts `.gitea/workflows/release.yml`, which does everything that needs only a gitea token: the container image, the Linux and Windows desktop packages, and F-Droid's build. Both runs attach to the same draft gitea release, and whichever attaches the last required file publishes it.
## Where each target ships from
| target | built by | ships to | finished by |
| --- | --- | --- | --- |
| Web / self-hosting | `image` job in `release.yml` | `gitea.coracle.social/coracle/flotilla:<version>` and `:latest` | automatic |
| Android APK | `apk` step, distribution key | `flotilla-<version>.apk` on the gitea release | automatic |
| Obtainium | the gitea release (and its GitHub copy) | users' Obtainium, by source url | automatic |
| zapstore | `zapstore` step, `zsp` | zapstore relays | automatic |
| Google Play | `play` step, upload key | a `draft` release on the `production` track | rolling it out in Play Console |
| iOS | `ios` step | build uploaded and attached to the App Store version, with What's New | submitting for review in App Store Connect |
| macOS | `desktop` step on the Mac, signed and notarized | gitea release + `latest-mac.yml` | automatic |
| Linux, Windows | `desktop` step in CI (Windows in the `electronuserland/builder` container) | gitea release + `latest-linux.yml`, `latest.yml` | automatic |
| F-Droid | `fdroid` (CI) builds unsigned, `fdroid-sign` (local) signs | `flotilla-fdroid` generic package on gitea, which F-Droid verifies its own build against | F-Droid's bot, from the tag |
| GitHub mirror | `mirror.yml` | tags on push, the latest published release every 3 hours | automatic |
Gitea's latest release is the desktop update feed, so a release stays a draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml` manifests.
## Layout
- `scripts/release/local.mjs` and `ci.mjs` list their steps; each step is a module in `scripts/release/steps/` with `missing()` (preflight), `setup` (how to fix it) and `run()`.
- `scripts/release/lib/pipeline.mjs` checks everything before anything runs: the tag exists, is pushed and is HEAD; `CHANGELOG.md` has a section for the version; the fastlane changelog matches it; `node_modules` matches `pnpm-lock.yaml`; and each step's credentials and tools. `--check` stops there. A failed step prints the command to resume from it.
- `lib/context.mjs` reads the version from `package.json`, the version code from `android/app/build.gradle`, and the notes from `CHANGELOG.md`. `shortNotes` is the notes cut at the last whole line under 500 characters, for Play and F-Droid.
- `scripts/release/bump.mjs` (`pnpm bump`) sets the version in `package.json`, Android and iOS, bumping each platform's build number only when its marketing version changes.
- `scripts/release/github.mjs` copies gitea's latest release to GitHub, run by `mirror.yml`.
- `scripts/fdroid/reproduce.sh` is F-Droid's build, run in their buildserver image; `fdroid/metadata/social.flotilla.fdroid.yml` is the recipe, mirrored in fdroiddata.
- `fastlane/metadata/android/en-US/` is F-Droid's listing, read from the tag.
The steps are safe to rerun. `play` finishes a release from a bundle Play already has when the local AAB is byte-identical to it, `ios` reuses a build number App Store Connect already has and leaves an already-submitted version alone, `gitea` replaces same-named assets, and the GitHub copy skips files whose size matches.
## Credentials
Local ones go in `.env.local`; `pnpm release:check` names whatever is missing and how to get it.
| variable | used by |
| --- | --- |
| `GITEA_TOKEN` (`write:repository`, `write:package`) | `gitea`, `fdroid-sign` |
| `ANDROID_KEYSTORE_*` | `apk`, `fdroid-sign`; the distribution key, which can never change |
| `PLAY_KEYSTORE_*`, `PLAY_SERVICE_ACCOUNT` | `play` |
| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | `ios`, and notarizing macOS |
| `CSC_NAME` | `desktop` on macOS (Developer ID Application certificate) |
| `SIGN_WITH` | `zapstore` |
| `DOCKER` | optional, e.g. `podman`, for container builds run from the Mac |
CI uses the job's own token for the release, and the `PACKAGE_TOKEN` and `GH_MIRROR_TOKEN` secrets for the container registry and F-Droid package, and for GitHub.
## Checklist
### Before tagging
- [ ] `git pull` on dev, then `pnpm install --frozen-lockfile` and `npm ci --prefix electron`. The release refuses stale dependencies; building against them once shipped Capacitor 8.3.4 native code with 8.5.2 Swift.
- [ ] `pnpm bump patch` (or `minor`, `major`, `x.y.z`). Always pass the argument.
- [ ] Write the `# <version>` section of `CHANGELOG.md`. The first ~500 characters are what Play and F-Droid show, so lead with what matters.
- [ ] `pnpm release:changelog` to write `fastlane/.../changelogs/<versionCode>.txt`.
- [ ] New F-Droid screenshots or listing text, if the UI changed, go in `fastlane/` now; F-Droid only reads them from the tag.
- [ ] If the F-Droid build environment changed (Node major in `.nvmrc`, the JDK, the fdroid scripts), update the recipe in `fdroid/metadata/` and open an fdroiddata merge request with the same change.
- [ ] `git add -A && git commit`, `pnpm release:check`, then `git tag <version>` and `git push origin dev <version>`.
### Releasing
- [ ] Start `pnpm release:local` once the tag is pushed. `fdroid-sign` waits for CI's F-Droid build, up to two hours.
- [ ] Watch the Release workflow run for the tag in gitea's Actions tab. Its `image` and `release` jobs must both pass.
- [ ] When a step fails, fix the cause and rerun the command the run prints, which resumes from that step. For a CI step, push the fix to dev and run the Release workflow by hand from dev with the steps to rerun, e.g. `fdroid`; it builds the tag's commit with dev's scripts, so the tag never has to move.
### After both runs
- [ ] The gitea release is published, not a draft, with `flotilla-<version>.apk`, the macOS DMGs and ZIPs, the AppImage, the Windows installer, and `latest.yml`, `latest-linux.yml`, `latest-mac.yml`.
- [ ] Play Console: review the draft on the production track and roll it out.
- [ ] App Store Connect: submit the version for review once the build is attached.
- [ ] The `flotilla-fdroid` package has `flotilla-fdroid-<version>.apk` for this version.
- [ ] Within three hours, GitHub's latest release is this version, with the APK.
- [ ] Within a few days, F-Droid shows the version. A failed reproducibility check shows up in F-Droid's build logs for `social.flotilla.fdroid`.
## Mistakes that have bitten before
- **Moving a tag publishes its draft.** Force-pushing a tag when a draft release exists for it makes gitea clear the draft flag. It also restarts the Release workflow, which rebuilds the image and replaces the CI-built files. Once F-Droid has built a tag, never move it: F-Droid won't rebuild.
- **Version codes are single-use.** Play and App Store Connect never accept a version code or build number twice. When an upload of the wrong build is already there, bump `versionCode` in `android/app/build.gradle` or `CURRENT_PROJECT_VERSION` in the Xcode project, not the version.
- **The Play service account** needs its app permissions saved in Play Console, and new access can take up to a day to reach the API ("The caller does not have permission").
- **`cap sync` rewrites native files** (`ios/App/Podfile`, `AndroidManifest.xml`, `capacitor.settings.gradle`) during `web`. On stale dependencies it points them at the wrong versions; revert them rather than committing.
- **Uploading a build is not releasing it.** Play stays a draft and iOS waits for review until someone acts on the follow-ups the run prints.
- **The F-Droid recipe lives in two places.** Our CI builds from `fdroid/metadata/`, F-Droid builds from fdroiddata. If they drift, our apk stops matching theirs and F-Droid won't ship it.

View file

@ -1,6 +1,6 @@
--- ---
name: flotilla-state name: flotilla-state
description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)." description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → publication, optimistic updates, undo, showing publish status)."
--- ---
# Flotilla state # Flotilla state
@ -8,7 +8,7 @@ description: "Use this skill when deciding where a piece of state belongs in Flo
State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in
the current app's repository. Plugin indexes and derived stores read the repository, and the current app's repository. Plugin indexes and derived stores read the repository, and
components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the
command becomes a thunk, and the thunk writes its event into the repository before any relay has command becomes a publication, and the `Publisher` writes its event into the repository before any relay has
seen it. seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app. Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
@ -36,7 +36,7 @@ The app is therefore stable for as long as anything under the login gate is moun
| `session` | the persisted `Session` | `undefined` | | `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws | | `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe | | `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe |
| `profiles`, `rooms`, `relays`, `thunks`, … | `usePlugin` for 27 welshman plugins | safe | | `profiles`, `rooms`, `relays`, `events`, `publisher`, … | `usePlugin` for 28 welshman plugins | safe |
| `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe | | `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe |
| `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` | | `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` |
| `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults | | `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults |
@ -62,8 +62,8 @@ The following run outside that gate:
- every app policy - every app policy
This code reads `app.get().user?.pubkey` and bails when it is missing, as This code reads `app.get().user?.pubkey` and bails when it is missing, as
`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts` `syncUserSpaceMembership` in `src/app/sync.ts` and `syncCheckedRemote` in
do. `src/app/notifications.ts` do.
Stores derived from `user` throw as soon as they are subscribed signed out. That includes Stores derived from `user` throw as soon as they are subscribed signed out. That includes
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom` `isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
@ -88,8 +88,9 @@ The rule is about when a binding is made:
- **Module scope, and anything outside the gate**, goes through `app`, `usePlugin`, `fromApp` - **Module scope, and anything outside the gate**, goes through `app`, `usePlugin`, `fromApp`
or `deriveUserItem`. A module-level `app.get()` builds the first app before the policies or `deriveUserItem`. A module-level `app.get()` builds the first app before the policies
register, and a binding made that way keeps reading the discarded app after login. Long-lived register, and a binding made that way keeps reading the discarded app after login. A
listeners re-bind on `app.subscribe`, as `chatsById` in `src/app/chats.ts`, long-lived listener over the repository belongs on a plugin, as in `Chats` (`chats.ts`), so
it is dropped with the app. Other listeners re-bind on `app.subscribe`, as
`syncCheckedRemote` in `notifications.ts` and the resync in the root layout do. `syncCheckedRemote` in `notifications.ts` and the resync in the root layout do.
- **Code under the gate** can bind at call time. `rooms.get().forUrl(url).$` inside - **Code under the gate** can bind at call time. `rooms.get().forUrl(url).$` inside
`deriveUserRooms`, or `$app.use(X)` in a component's script, is fine there, because the app `deriveUserRooms`, or `$app.use(X)` in a component's script, is fine there, because the app
@ -105,7 +106,8 @@ Bookkeeping for one identity lives on a plugin instance, so it is discarded with
`Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason. `Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason.
Module-level caches are fine when their contents do not depend on who is signed in, or when what Module-level caches are fine when their contents do not depend on who is signed in, or when what
they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores. they cache is itself a rebinding store. `deriveCommandsForUrl` (`commands.ts`) caches `fromApp`
stores with `simpleCache`.
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from `hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
`@welshman/lib` to share one store per url across every component that asks. `@welshman/lib` to share one store per url across every component that asks.
@ -117,6 +119,11 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
| `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey | | `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey |
| `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay | | `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay |
| `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s | | `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s |
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
| `GoalProgresses` (`goals.ts`) | none | one memoized NIP-75 progress store per goal and relay, behind `deriveGoalProgress` |
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
Each is exposed with `usePlugin`. `Statuses` is the minimal shape: Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
@ -170,20 +177,23 @@ app, and `app.get()` recurses into building another one.
## The repository and derived state ## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing `repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher`
optimistically. The tracker records which relays each event was seen on, which is what lets publishing optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay. space content be keyed by relay.
`src/app/repository.ts` wraps the `@welshman/store` derivations in `fromApp`: Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`.
Every method returns a projection over the current app's repository:
- `deriveEvent`, `deriveEvents`, `deriveEventsById`, `deriveIsDeleted` - `one(idOrAddress, relays)` (loads when nothing local matches, keeps deleted events), `byId`,
- relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`, `all`, `asc`, `desc`, `isDeleted`
`getEventsForUrl` - relay-scoped through the tracker: `forUrl`, `byIdForUrl`, `byIdByUrl`, and
- `deriveLatestEvent` `relaySignedForUrl`, which keeps only events signed by the relay's NIP-11 `self`
Use these for raw event queries. Welshman's `Events` plugin has the same surface returning Components bind `$events.forUrl(url, filters).$`. A `derive*` function called from gated code
projections, and flotilla does not use it. Plugin reads (`get`, `one`, `load`, `index`) are binds `events.get().forUrl(...).$` at call time, and a module-scope store wraps it in
documented in welshman-app. `fromApp($app => $app.use(Events).byIdByUrl(filters).$)`, as `latestActivityByPath` does.
`src/app/repository.ts` holds only the `LatestEvents` plugin. Plugin reads (`get`, `one`, `load`,
`index`) are documented in welshman-app.
### Free functions in an app module ### Free functions in an app module
@ -195,14 +205,14 @@ projections and repository derivations:
export const deriveSpaceActionItems = (url: string) => export const deriveSpaceActionItems = (url: string) =>
derived( derived(
[ [
deriveEventsForUrl(url, [{kinds: [REPORT]}]), events.get().forUrl(url, [{kinds: [REPORT]}]).$,
rooms.get().pendingJoins(url).$, rooms.get().pendingJoins(url).$,
deriveSpaceSupportedMethods(url), deriveSpacePermissions(url),
], ],
([$reports, $pendingJoins, $methods]) => ([$reports, $pendingJoins, $permissions]) =>
sortEventsDesc([ sortEventsDesc([
...($methods.includes("banevent") ? $reports : []), ...($permissions.deleteContent ? $reports : []),
...($methods.includes("allowpubkey") ? $pendingJoins : []), ...($permissions.addMembers ? $pendingJoins : []),
]), ]),
) )
``` ```
@ -220,14 +230,15 @@ Rules that involve more than one plugin belong in these functions rather than in
A derivation that every row subscribes to, or that joins large sets, is built by hand: A derivation that every row subscribes to, or that joins large sets, is built by hand:
- `chatsById` (`chats.ts`) updates incrementally from repository `update` events rather than - `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather
re-querying. than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array profiles index.
wherever an event's thunks are unchanged so rows don't churn. - `publicationsByEventId` (`publications.ts`) indexes publish history once, and hands back the
previous array wherever an event's publications are unchanged so rows don't churn.
- `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and - `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`. settings behind `throttled(1000, …)`.
- `deriveLatestEvent` (`repository.ts`) shares one repository listener across every watched - `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
author. watched author.
## Local and persisted state ## Local and persisted state
@ -236,7 +247,7 @@ A derivation that every row subscribes to, or that joins large sets, is built by
| repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted | | repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted | | settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `session`, `wallet` | `ss` | no | cleared | | `session`, `wallet` | `ss` | no | cleared |
| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state | `kv` | no | cleared | | `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state, `forceHealthChecks` | `kv` | no | cleared |
| drafts, dictations | a module `Map`, lost on reload | no | page reloads | | drafts, dictations | a module `Map`, lost on reload | no | page reloads |
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`) ### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
@ -262,21 +273,30 @@ Room messages, threads and other content are not kept, and background sync pulls
next tick. next tick.
To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin, To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin,
add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe add a `TABLES` entry and an `init*` method that calls `persistMapPlugin` with the table, the
to `onItem`, and batch the writes. plugin, and how to turn a row into a key and item and back. It loads the rows, subscribes to
`onItem`, and batches the writes.
### kv, ss and the two ways to bind them ### kv, ss and synced stores
`kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from `kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from
`storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so `storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so
anything in them outlives a login and is cleared only by logout. Secrets go in `ss`. anything in them outlives a login and is cleared only by logout. Secrets go in `ss`.
- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the Each persisted store is declared in its owner module with `synced({key, storage, defaultValue})`,
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme` or `withGetter(synced(...))` when callers need `.get()`, which keeps `.ready`. The store emits the default first and loads the stored value in the background, so
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it. `.ready` resolves once it has arrived:
- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it
for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot - `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`),
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists. `device` (`device.ts`), `forceHealthChecks` (`healthChecks.ts`) in `kv`
- `pushState` (key `notificationState`) and `notificationSettings` in `kv`, both in
`push/adapters/common.ts` and re-exported from `@app/push`
- `wallet` (`lightning.ts`) in `ss`
The root layout awaits `.ready` for the stores boot code reads (`device`, `shouldUnwrap`,
`wallet`, `notificationSettings`, `pushState`, `forceHealthChecks`) before `Push.sync()` and
background sync start. A new store that boot reads joins that list. Never change a key, since
it is where existing installs keep their value.
Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them
there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme. there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme.
@ -295,7 +315,7 @@ Settings are an encrypted app-data event with d-tag `flotilla/settings`, read th
A preference that should follow the user across devices goes in `SettingsValues` and A preference that should follow the user across devices goes in `SettingsValues` and
`defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do `defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do
in `notificationSettings`. Per-space alert preferences are published (`alerts`); the device's in `notificationSettings` (`@app/push`). Per-space alert preferences are published (`alerts`); the device's
push permission is not. push permission is not.
`checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to `checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to
@ -313,7 +333,7 @@ its composer has gone.
## Background sync (`src/app/sync.ts`) ## Background sync (`src/app/sync.ts`)
The root layout calls `syncApplicationData()` once the session is restored and storage is ready, The root layout calls `syncApplicationData()` once the session is restored and storage is ready,
and again after every app swap. `Access.completeJoin` calls it after a space is joined. Each and again after every app swap. `completeSpaceJoin` in `access.ts` calls it after a space is joined. Each
call tears down the previous run. call tears down the previous run.
- `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's - `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's
@ -327,8 +347,9 @@ call tears down the previous run.
- `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on. - `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on.
`pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through `pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through
an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against an `AbortController`. `syncUserData`, `syncSpaces` and `syncDMs` each keep one sync per url with
the room list, so a new filter goes into the right `pullAndListen` call. `makeKeyedSyncs`, whose `reconcile(urls)` starts the new ones and stops the dropped ones. A run
reads the pubkey once when it starts, since login triggers a fresh `syncApplicationData`.
Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data
that must be current app-wide belongs here. Data only one page shows is loaded by that page's that must be current app-wide belongs here. Data only one page shows is loaded by that page's
@ -336,7 +357,7 @@ components; see flotilla-views.
## Mutations ## Mutations
The prevailing path runs from a domain writer to a command to a thunk, adapted from The prevailing path runs from a domain writer to a command to a publication, adapted from
`ThreadCreate.svelte`: `ThreadCreate.svelte`:
```typescript ```typescript
@ -350,8 +371,8 @@ if (room) {
eventWriter.setRoom(url, room) eventWriter.setRoom(url, room)
} }
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -368,44 +389,45 @@ Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(
`forceLoad` before writing. A replaceable event you build yourself needs the same, as in `forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`. `publishSettings`.
Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that Some call sites call `publisher.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and (`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`publishRoomQuote` in `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`). `wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return
`{result, error}`, and the caller handles `error`. `{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status ### Optimistic updates, undo and status
- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its - **Optimistic writes.** `Publisher` writes the event into the repository and tracks it against its
relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
unsigned event for the signed one. unsigned event for the signed one, and `publication.event` follows it.
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from - **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts, the event from the repository and from `history`. When `send_delay` is set, room chat shows a
and a comment carries the same Cancel in the `ThunkPending` row under it. `PublicationToast` whose Cancel button aborts, and a comment carries the same Cancel in the
`PublicationPending` row under it.
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see - **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`). `RoomChat.svelte`).
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass - **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay. pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the relay. A retry drops the publications it replaces from `history`, so the row shows the retry's
whole `history` per row instead. outcome. `PublicationStatusOrDeleted` combines publish status with deletion.
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt - **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt
above. above.
## Other app-level stores ## Other app-level stores
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then - **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
`allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two `allNotifications`, then `notifications` and the counts. The inbox stores
stores, so the inbox matches the badges. (`inboxConversations`, `inboxSpaceContent`) derive from the same two, so the inbox matches the
badges.
- **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`, - **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
`currentCallSession`, …). `currentCallSession`, …).
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`. - **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are - **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates.
classes a component instantiates (`new Access(url)`). They hold the writables and actions for It holds the writables and actions for a multi-step flow.
a multi-step flow. - **Module-owned values.** `wallet` in `lightning.ts` persists itself to `ss` with
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the `withGetter(synced(...))`.
root layout persists.
## Runes and stores ## Runes and stores
@ -423,7 +445,7 @@ store, consumed in components with `$store`.
Take the first answer that fits: Take the first answer that fits:
1. **It is an event, or derived from events.** It is already in the repository, or should be. 1. **It is an event, or derived from events.** It is already in the repository, or should be.
Read it with a plugin or an `@app/repository` derivation, and put the domain logic in a Read it with a plugin or the `events` projections, and put the domain logic in a
`derive*` function in the owning app module (`deriveUserRooms`). Don't copy it into a `derive*` function in the owning app module (`deriveUserRooms`). Don't copy it into a
writable. writable.
2. **It is a keyed collection of one kind, loaded by key.** Write a plugin. A generic kind goes 2. **It is a keyed collection of one kind, loaded by key.** Write a plugin. A generic kind goes
@ -445,7 +467,7 @@ Take the first answer that fits:
- `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance - `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance
- `flotilla-views`: routes, components, and how components load data and show state - `flotilla-views`: routes, components, and how components load data and show state
- `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds - `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds
- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events` - `welshman-app`: `App`, plugins, `Command`, `Publisher`, `Network`/`Sync`, `Events`
- `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter` - `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command` - `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app - `welshman-net`: the repository, tracker and socket policies under the app

View file

@ -56,7 +56,8 @@ const url = decodeRelay(relay)
That is safe because the layouts remount their children when a param changes. That is safe because the layouts remount their children when a param changes.
`spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and `spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and
`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` keys on `?at=`, and `chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` and the space chat page key on
`?at=` and `?event=` (which is how a room transcript re-anchors on a permalink), and
`people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`, `people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`,
or its page has to derive from `$page` instead. Routes read `$page` from the deprecated or its page has to derive from `$page` instead. Routes read `$page` from the deprecated
`$app/stores`; only the two modal modules use `page` from `$app/state`. `$app/stores`; only the two modal modules use `page` from `$app/state`.
@ -99,7 +100,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves
`makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`). `makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`).
- `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it - `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it
for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`, for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`,
`A` and `E` tags), and falls back to `entityLink` from `src/app/env.ts`, an external link. `A` and `E` tags), and returns `undefined` for an event with no page in the app. A caller that
wants an external link then calls `entityLink` from `src/app/env.ts` itself, as
`makeEventPermalink` does.
- `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute - `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute
and navigates otherwise; `makeEventPermalink` is the shareable form. and navigates otherwise; `makeEventPermalink` is the shareable form.
- `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which - `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which
@ -110,9 +113,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves
and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`. and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`.
Without one the tab shows only `PLATFORM_NAME`. Without one the tab shows only `PLATFORM_NAME`.
Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`), Deep links arrive in `handleDeepLink` in `src/app/deepLinks.ts`: push-notification links
the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a (`?relay=&id=`), the iOS share extension (the `share` host), signer returns (`x-callback-url`), and
plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and otherwise a plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
loads events before calling `goToEvent`. loads events before calling `goToEvent`.
### Adding a space content page ### Adding a space content page
@ -187,15 +190,22 @@ behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
### Popover menus ### Popover menus
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop `MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
that hides it: that hides it. It shows the menu-dots icon unless given children, opens `bottom-end` unless given a
`placement`, and takes `strayMargin` to hide once the pointer strays that many pixels from the
popover, as the hover actions on a chat row do:
```svelte ```svelte
<MenuButton component={ChatMenu} aria-label="Chat options" /> <MenuButton component={ChatMenu} aria-label="Chat options" />
``` ```
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item `EventMenu` puts `onClick` on its `<ul>` as `onclickcapture`, so any item closes the popover, and
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes each item usually pushes a modal. It has to be the capture phase: `Button` stops propagation, and
`RoomItemMenuMobile` on tap. Svelte's delegated `onclick` on the `<ul>` would never see the click. On mobile, rows push a
`*MenuMobile` modal instead: `RoomItem` pushes `RoomItemMenuMobile` on tap.
`Tooltip` is `Tippy` with a string `content` and the `tooltip` theme, whose CSS lives in
`tooltip.css`. `Tippy` appends popovers to the page's `.tippy-target`, or to the open dialog's
`.dialog-tippy-target` so they stack above it.
### Toasts ### Toasts
@ -204,7 +214,7 @@ usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `Roo
```typescript ```typescript
pushToast({message: "Role created!"}) pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}}) pushToast({theme: "error", message, action: {message: "Details", onclick}})
pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}}) pushToast({timeout: 30_000, children: {component: PublicationToast, props: {publication}}})
``` ```
A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies
@ -220,16 +230,19 @@ lives there:
- page chrome: `Page`, `PageBar`, `PageContent`, `SecondaryNav*`, `PrimaryNavItem`, `FAB` - page chrome: `Page`, `PageBar`, `PageContent`, `SecondaryNav*`, `PrimaryNavItem`, `FAB`
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`, - modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
`ModalSubtitle`, `ModalFooter`, `Confirm` `ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`, - controls: `Button`, `Link`, `Field`, `FieldInline`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton` `DateTimeInput`, `ImagesInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, - display and lists: `Icon`, `ImageIcon`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop` `Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens - the CSS component families (`button.css`, `card.css`, `badge.css`, `input.css`, …) and the
theme tokens. Cards, badges and inputs are plain markup with these classes
(`<div class="card card-sm">`, `<span class="badge badge-primary">`,
`<div class="input input-group"><input /></div>`) rather than components.
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
component. A lib component that needs app behavior takes it as a prop, such as `MenuButton`'s component. A lib component that needs app behavior takes it as a prop, such as `MenuButton`'s
`component`. Three lib components import `@app` anyway; flotilla-architecture lists them under `component`. `Link` imports `@app` anyway; flotilla-architecture lists it under its layer
its layer exceptions. exceptions.
`src/app/components` is flat, and `hosting/` is the only subdirectory it has ever had, brought in `src/app/components` is flat, and `hosting/` is the only subdirectory it has ever had, brought in
whole by the caravel port (cf938c63) with names that would blur into the flat `Relay*` family. whole by the caravel port (cf938c63) with names that would blur into the flat `Relay*` family.
@ -238,8 +251,7 @@ New components go in the flat folder.
## Naming ## Naming
Names are `<Entity><Qualifier>`: the prefix says what it's about, the suffix what it does, which Names are `<Entity><Qualifier>`: the prefix says what it's about, the suffix what it does, which
keeps families adjacent in a flat listing. `ClassifiedActions`, `ClassifiedCreate`, keeps families adjacent in a flat listing. `ClassifiedCreate`, `ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
`ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
Name a modal for what it does (`SpaceJoin`, `ClassifiedCreate`, `EventDeleteConfirm`); only a Name a modal for what it does (`SpaceJoin`, `ClassifiedCreate`, `EventDeleteConfirm`); only a
handful carry a `Modal` or `Dialog` suffix. The suffix vocabulary, the prefix families, and the handful carry a `Modal` or `Dialog` suffix. The suffix vocabulary, the prefix families, and the
@ -254,7 +266,7 @@ stores, so a component takes the key and looks up the rest: `RoomName` takes `{u
`$rooms.forRoom(url, h)`, `ProfileName` takes `pubkey` and reads `$profiles.display`. `$rooms.forRoom(url, h)`, `ProfileName` takes `pubkey` and reads `$profiles.display`.
Events are the exception, and 66 components take `event: TrustedEvent`, since the parent already Events are the exception, and 66 components take `event: TrustedEvent`, since the parent already
holds the event from a feed or a `deriveEvent`. An event prop usually travels with `url`, the holds the event from a feed or `$events.one`. An event prop usually travels with `url`, the
relay it lives on, which routes its replies and reactions, and with `context: FeedContext`, the relay it lives on, which routes its replies and reactions, and with `context: FeedContext`, the
shared loader below. A component takes a pointer instead only when it has to load the event shared loader below. A component takes a pointer instead only when it has to load the event
itself, as `ContentQuote` does. Anything with neither a store nor an event is passed as its itself, as `ContentQuote` does. Anything with neither a store nor an event is passed as its
@ -262,7 +274,7 @@ domain reader: `RoleEdit` takes `role: RelayRoleReader`.
Identifier props are read once, `const room = $rooms.forRoom(url, h)`, since pages remount on a Identifier props are read once, `const room = $rooms.forRoom(url, h)`, since pages remount on a
param change and lists key by id. A prop that does change while mounted needs `$derived`: param change and lists key by id. A prop that does change while mounted needs `$derived`:
`ClassifiedActions` reads its event that way, because editing a listing hands it a new version. `ContentActions` reads its event that way, because editing a listing hands it a new version.
### The rest of the vocabulary ### The rest of the vocabulary
@ -276,12 +288,12 @@ param change and lists key by id. A prop that does change while mounted needs `$
- `$bindable` is for input-like components: `value` on `TopicMultiSelect` and - `$bindable` is for input-like components: `value` on `TopicMultiSelect` and
`ProfileMultiSelect`, `element` on `PageContent`, `notifications` on `SpaceJoinNotifications`. `ProfileMultiSelect`, `element` on `PageContent`, `notifications` on `SpaceJoinNotifications`.
- Display flags are fine (`showRoom`, `showActivity`, `hideZap`, `class`); derived data is not. - Display flags are fine (`showRoom`, `showActivity`, `hideZap`, `class`); derived data is not.
- 24 app components and 20 lib components declare `interface Props`, against 232 using `type`. - Props are declared as `type Props`, never an `interface` or an inline object type.
## Reading state in a component ## Reading state in a component
Components read plugins through the `usePlugin` stores exported from `src/app/core.ts` Components read plugins through the `usePlugin` stores exported from `src/app/core.ts`
(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$network`, `$thunks`, `$deletes`, (`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$events`, `$network`, `$publisher`, `$deletes`,
`$relayManagement`, …). `$app.use(X)` covers plugins with no export, such as `Zappers`, `$relayManagement`, …). `$app.use(X)` covers plugins with no export, such as `Zappers`,
`Pinboards` and `Feeds`; flotilla-state has the rules about which to use where. `Pinboards` and `Feeds`; flotilla-state has the rules about which to use where.
@ -291,6 +303,7 @@ How you bind depends on what the method returns:
<script lang="ts"> <script lang="ts">
const room = $rooms.forRoom(url, h) // Readable: one, forRoom const room = $rooms.forRoom(url, h) // Readable: one, forRoom
const display = $profiles.display(pubkey, removeUndefined([url])).$ // Projection: take .$ const display = $profiles.display(pubkey, removeUndefined([url])).$ // Projection: take .$
const replies = $events.asc(filters).$ // raw repository queries
const shouldProtect = $relays.hasNip(url, 70) // Promise: load, hasNip const shouldProtect = $relays.hasNip(url, 70) // Promise: load, hasNip
</script> </script>
@ -298,7 +311,7 @@ How you bind depends on what the method returns:
``` ```
In a handler, call `.get()` on a projection instead of subscribing. The app modules add `derive*` In a handler, call `.get()` on a projection instead of subscribing. The app modules add `derive*`
factories over the same data: `deriveEvent` and `deriveEventsById` in `src/app/repository.ts`, factories over the same data: `deriveSpaceActionItems` in `src/app/actionItems.ts`,
`deriveSpaceSupportedMethods` in `src/app/management.ts`, `deriveRelayAuthError` in `deriveSpaceSupportedMethods` in `src/app/management.ts`, `deriveRelayAuthError` in
`src/app/access.ts`. Call them at the top of the script with fixed arguments, and wrap one in `src/app/access.ts`. Call them at the top of the script with fixed arguments, and wrap one in
`$derived` only when its arguments change, as the thread page does for filters that wait on the `$derived` only when its arguments change, as the thread page does for filters that wait on the
@ -316,19 +329,18 @@ data every page needs lives in `src/app/sync.ts`.
`people/[npub]/+page.svelte` does for the profile, relay list, follow, pin, room and messaging `people/[npub]/+page.svelte` does for the profile, relay list, follow, pin, room and messaging
lists before loading the author's outbox with `$network.loadLenient`. lists before loading the author's outbox with `$network.loadLenient`.
**A detail page:** `deriveEvent(address, [url])` loads when nothing local matches. While it is **A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is
empty, seven pages show a spinner that turns into a failure message: empty, seven pages show `LoadingFallback`, a spinner that turns into a failure message after
five seconds:
```svelte ```svelte
{#await sleep(5000)} <LoadingFallback
<Spinner loading>Loading listing...</Spinner> loadingMessage="Loading listing..."
{:then} failureMessage="Failed to load classified listing." />
<p>Failed to load classified listing.</p>
{/await}
``` ```
**Related events:** request them, abort on teardown, and read them back from the repository with **Related events:** request them, abort on teardown, and read them back from the repository with
`deriveEventsAsc(deriveEventsById(filters))`. `$events.asc(filters).$`.
```typescript ```typescript
onMount(() => { onMount(() => {
@ -373,7 +385,7 @@ onMount(() => {
The feed is built in `onMount` because the loader needs the bound scroll element from The feed is built in `onMount` because the loader needs the bound scroll element from
`<PageContent bind:element>`. `context.add` batches the reactions, comments and deletions for `<PageContent bind:element>`. `context.add` batches the reactions, comments and deletions for
every event the feed yields, rows get the same `context`, and their `*Actions` read every event the feed yields, rows get the same `context`, and their action rows read
`context.related(event)` and `context.deleted(event)`. Close the list with `<Spinner {loading}>` `context.related(event)` and `context.deleted(event)`. Close the list with `<Spinner {loading}>`
and an `{#if}` chain over loading, empty and exhausted. Calendars use and an `{#if}` chain over loading, empty and exhausted. Calendars use
`makeCalendarFeed`, which pages by date tag rather than `created_at`. `makeCalendarFeed`, which pages by date tag rather than `created_at`.
@ -386,22 +398,27 @@ ahead. All ten `*Item` components that appear in a list use it.
## Mutations from a component ## Mutations from a component
flotilla-state covers the writer → command → thunk path. The component around it owns a flotilla-state covers the writer → command → publication path. The component around it owns a
`loading` flag, awaits the first error, toasts it, and closes: `loading` flag, awaits the first error, toasts it, and closes. Anything thrown on the way (a signer
refusing, a relay unreachable) is logged and toasted in a `catch`, so a bare `try`/`finally` never
lets it escape the handler:
```typescript ```typescript
const submit = async () => { const submit = async () => {
loading = true loading = true
try { try {
const thunk = await command(eventWriter).then(publish) const publication = await command(eventWriter).then(publish)
const error = await thunk.waitForError() const error = (await publication.settled()).getError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} }
history.back() history.back()
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to save your changes."})
} finally { } finally {
loading = false loading = false
} }
@ -411,20 +428,20 @@ const submit = async () => {
Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators
return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then
`.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` then `.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` then
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk: `.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a publication:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`. `$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
optimistic, so rows show progress in place: the seven per-kind `*Actions` components wrap their optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`. wrap their contents in `PublicationStatusOrDeleted`, and chat pushes a `PublicationToast`.
## Forms ## Forms
`Field` puts a label above its control, with optional `secondary` and `info` snippets. `Field` puts a label above its control, with optional `secondary` and `info` snippets.
`FieldInline` puts the label left and the control right, as settings and detail rows do. Controls `FieldInline` puts the label left and the control right, as settings and detail rows do. Controls
are plain elements styled by class (`<label class="input …">`, `<select class="select input">`), are plain elements styled by class (`<label class="input …">`, `<select class="select input">`),
or bindable inputs from lib (`ImagesInput`, `IconInput`) and app (`TopicMultiSelect`). or bindable inputs from lib (`ImagesInput`) and app (`IconInput`, `TopicMultiSelect`).
Create/edit pairs come in three shapes: Create/edit pairs come in three shapes:
@ -437,31 +454,37 @@ Create/edit pairs come in three shapes:
- **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey` - **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey`
and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit` and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit`
turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit` turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit`
restates the shape; export it instead. restates the shape; export it instead, as `ArticleForm` does. `ArticleForm` is the same shape
on a full page: it renders the `SpaceBar` with its Publish button, takes the title as `header`,
and lands on the published article.
Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`. Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
## Styling ## Styling
- Classes come from the component families in `src/lib/components/*.css`, which `theme.css` - Classes come from the component families in `src/lib/components/*.css`, which `theme.css`
imports: `button` with `button-primary|neutral|link|ghost|error` and imports, and the app-concept families in `src/app/components/*.css`, which `src/app/app.css`
`button-sm|xs|circle|square`, `card`, `badge`, `input`, `select`, `textarea`, `menu`. These are imports after `theme.css`. A class goes in `@layer components` either way. The lib families
flotilla's own; daisyUI is not installed. include `button` with `button-primary|neutral|link|ghost|error` and `button-sm|xs|circle|square`,
`card`, `badge`, `input`, `select`, `textarea` and `menu`. These are flotilla's own; daisyUI is
not installed.
- Colors are the semantic tokens registered in `base.css`: `bg-surface`, `bg-surface-more`, - Colors are the semantic tokens registered in `base.css`: `bg-surface`, `bg-surface-more`,
`text-content`, `text-content-muted`, `border-line`, `text-primary`, `text-error`. The clay, `text-content`, `text-content-muted`, `border-line`, `text-primary`, `text-error`. The clay,
flat and navy themes supply the values through `data-fl-theme`. flat, navy and nature themes supply the values through `data-fl-theme`.
- Seven `class:` directives remain as leftovers; everything else builds classes with `cx`. - Classes are built with `cx`; no `class:` directives remain.
- Container queries go on the `PageContent`, as in `@2xl:grid-cols-2` for the classifieds grid. - Container queries go on the `PageContent`, as in `@2xl:grid-cols-2` for the classifieds grid.
Beside the navs a page is about 326px narrower than the screen, so a screen `lg:` layout switch
becomes `@2xl:` inside a page, as on the home, about, goals, article and settings pages.
- Icons are `import X from "@assets/icons/<name>.svg?dataurl"` with `<Icon icon={X} size={4} />`, - Icons are `import X from "@assets/icons/<name>.svg?dataurl"` with `<Icon icon={X} size={4} />`,
where `size` counts 4px steps. List entries animate with `in:fly` from `@lib/transition`. where `size` counts 4px steps. List entries animate with `in:fly` from `@lib/transition`.
## Related skills ## Related skills
- `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement - `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement
- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, thunks and commands - `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, publications and commands
- `flotilla-model`: spaces, rooms, NIP-43/29/86, content kinds, and where events are published - `flotilla-model`: spaces, rooms, NIP-43/29/86, content kinds, and where events are published
- `welshman-app`: plugins, projections, `Command`, thunks, `Feeds` - `welshman-app`: plugins, projections, `Command`, `Publisher`, `Events`, `Feeds`
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the other repository stores - `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the repository stores under `Events`
- `welshman-domain`: the readers and writers behind `reader` and `writer` - `welshman-domain`: the readers and writers behind `reader` and `writer`
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed` - `welshman-feeds`: `FeedController`, still used by `ProfileFeed`
- `welshman-editor`: the composer behind `makeEditor` - `welshman-editor`: the composer behind `makeEditor`

View file

@ -8,21 +8,21 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
| Suffix | Means | Examples | | Suffix | Means | Examples |
|---|---|---| |---|---|---|
| `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` | | `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` |
| `Actions` | the footer row under an event: reactions, status, overflow menu | `ClassifiedActions`, `ThreadActions`, `GoalActions`, `EventActions` | | `Actions` | the footer row under an event: reactions, status, overflow menu | `ContentActions`, `CommentActions`, `EventActions` |
| `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` | | `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` |
| `MenuList` | the popover contents when `*Menu` is the trigger instead | `ProfileMenu` → `ProfileMenuList`, `ReportMenu` → `ReportMenuList` |
| `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` | | `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` |
| `Create` / `Edit` | a modal that publishes a new or edited event | `ClassifiedCreate`, `RoomEdit`, `PinEdit` | | `Create` / `Edit` | a modal that publishes a new or edited event | `ClassifiedCreate`, `RoomEdit`, `GoalCreate` |
| `Save` | one modal that creates or edits, depending on whether it's given the event | `PinSave` |
| `Form` | the fields shared by a Create/Edit pair | `ClassifiedForm`, `RoomForm`, `RoleForm` | | `Form` | the fields shared by a Create/Edit pair | `ClassifiedForm`, `RoomForm`, `RoleForm` |
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` | | `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` | | `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
| `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` | | `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` |
| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `ThunkStatus` | | `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `PublicationStatus` |
| `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` | | `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` |
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` | | `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` | | `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
| `Confirm` | the step that confirms an action or a code | `EventDeleteConfirm`, `LogInOTPConfirm`, `SignUpEmailConfirm` | | `Confirm` | the step that confirms an action or a code | `EventDeleteConfirm`, `LogInOTPConfirm`, `SignUpEmailConfirm` |
| `Add` | a modal that adds something to a collection | `SpaceAdd`, `PinAdd`, `RoomMembersAdd` | | `Add` | a modal that adds something to a collection | `SpaceAdd`, `RoomMembersAdd` |
| `Select` | a picker, as a modal or a bindable input | `PinboardSelect`, `TopicMultiSelect` | | `Select` | a picker, as a modal or a bindable input | `PinboardSelect`, `TopicMultiSelect` |
| `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` | | `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` |
| `Name`, `Image`, `Icon`, `Link`, `Circle` | one identifier rendered | `RoomName`, `RelayIcon`, `ProfileLink`, `ProfileCircle` | | `Name`, `Image`, `Icon`, `Link`, `Circle` | one identifier rendered | `RoomName`, `RelayIcon`, `ProfileLink`, `ProfileCircle` |
@ -41,7 +41,7 @@ per content kind). Four are families instead:
- `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one - `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one
`NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`. `NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`.
- `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`. - `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`.
- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Thunk*` show - `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Publication*` show
publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`). publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`, Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,

View file

@ -1,6 +1,6 @@
--- ---
name: welshman-app name: welshman-app
description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search." description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing through a staged pipeline, outbox-model requests, routing, web of trust, feeds, and search."
--- ---
# welshman/app — Instance-Based Nostr App # welshman/app — Instance-Based Nostr App
@ -26,7 +26,7 @@ Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade
2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app. 2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app.
3. **`Projection<T>` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths. 3. **`Projection<T>` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths.
4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive. 4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive.
5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo. 5. **Writes are optimistic.** Publishing goes through the `Publisher` pipeline: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
## Creating an app ## Creating an app
@ -96,7 +96,7 @@ setNip55Plugin(NostrSignerPlugin)
All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`. All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`.
Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below. Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Publication` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
| Plugin | Data | Notable accessors | | Plugin | Data | Notable accessors |
|---|---|---| |---|---|---|
@ -122,7 +122,7 @@ Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) i
| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` | | `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` |
| `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` | | `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` |
| `Reactions` / `Deletes` | kind-7 reactions and kind-5 deletes over the repository | reactive lookups | | `Reactions` / `Deletes` | kind-7 reactions and kind-5 deletes over the repository | reactive lookups |
| `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember(url, room, …)` → `Command` | | `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember`/`deleteEvent(url, room, …)` → `Command` |
| `Plaintext` | decrypted-content cache, keyed by ciphertext | `ensure(ciphertext, decrypt)`, `get(ciphertext)` | | `Plaintext` | decrypted-content cache, keyed by ciphertext | `ensure(ciphertext, decrypt)`, `get(ciphertext)` |
```typescript ```typescript
@ -144,7 +144,7 @@ const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[]
// Mutations return a Command — build it, then decide how to publish it // Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example") const command = await app.use(RelayLists).addWriteUrl("wss://relay.example")
command.publish() // normal outbox/relays flow via Thunks command.publish() // normal outbox/relays flow via Publisher
// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay // or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await: // Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await:
@ -152,41 +152,46 @@ import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish) await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
``` ```
## Publishing (optimistic thunks) ## Publishing (optimistic pipeline)
```typescript ```typescript
import {Thunks, Router} from "@welshman/app" import {Publisher, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util" import {makeEvent, NOTE, userOutbox} from "@welshman/util"
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the // Publisher has no outbox helper. Resolve write relays with the Router's Resolver and the
// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the // RelaySelection DSL, as Command.publish() does for every data-plugin mutation:
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes): const publication = app.use(Publisher).publish({
const thunk = app.use(Thunks).publish({
event: makeEvent(NOTE, {content: "hi"}), event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]> relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
delay: 3000, // abortable soft-undo window (ms) delay: 3000, // abortable soft-undo window (ms), before signing
}) })
// To specific relays: // To specific relays:
app.use(Thunks).publish({event, relays: ["wss://relay.example"]}) app.use(Publisher).publish({event, relays: ["wss://relay.example"]})
// A thunk is a Svelte store with per-relay status: // A Publication is a Svelte store. `state` is a union keyed by `stage`:
thunk.subscribe(t => console.log(t.results)) // queued → delayed → wrapping → pow → signing → sending → sent | failed | aborted
thunk.abort() // effective only before `delay` elapses publication.subscribe(p => console.log(p.state.stage, p.results))
await thunk.waitForCompletion() publication.abort() // unwinds the optimistic write; does nothing once settled
thunk.getError() // string | undefined publication.canAbort() // true until any relay has been sent to
app.use(Thunks).history // writable<Thunk[]> — optimistic log await publication.settled() // resolves on a terminal stage
app.use(Thunks).retry(thunk) publication.getError() // string | undefined
publication.event // current event (mined/signed as it goes); the rumor for a wrap
app.use(Publisher).history // writable<Publication[]> — optimistic log
app.use(Publisher).retry(publication) // re-sends publication.event, same id
app.use(Publisher).retry(publication, [url]) // only to these relays; a group retries only members that targeted them
// retry drops the publications it replaces from `history`
app.use(Publisher).merge([a, b]) // PublicationGroup; a relay's status is the worst among members
// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps: // Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps:
app.use(Thunks).publish({event, relays, recipient: theirPubkey}) app.use(Publisher).publish({event, relays, recipient: theirPubkey})
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]}) const group = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13): // Proof of work (NIP-13):
app.use(Thunks).publish({event, relays, pow: 20}) app.use(Publisher).publish({event, relays, pow: 20})
``` ```
`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. `PublishRequest`: `{event, relays, recipient?, delay?, pow?, priority?, timeout?}`. The stages (`STAGES` in `plugins/publisher.ts`) only transform the event. The publisher keeps the repository, tracker and `WrapManager` in step by reacting to state changes. Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message.
## Commands (deferred publishing) ## Commands (deferred publishing)
@ -201,11 +206,11 @@ command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it command.relays // string[] — where publish() will send it
command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays}) command.publish() // normal path: app.use(Publisher).publish({event, relays: command.relays})
command.publishToRelays(urls) // publish to a specific relay set instead of command.relays command.publishToRelays(urls) // publish to a specific relay set instead of command.relays
``` ```
This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly. This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `PublicationGroup` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
`publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting: `publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting:
@ -223,7 +228,10 @@ await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl]))
import {Network, Sync} from "@welshman/app" import {Network, Sync} from "@welshman/app"
const net = app.use(Network) const net = app.use(Network)
const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays}) // loadComplete waits for every relay, so an empty result means every relay answered
// nothing. loadLenient resolves once half of them have closed, for the cases where
// nothing routes on the returned array.
const events = await net.loadComplete({filters: [{kinds: [1], authors: [pk]}], relays})
await net.request({filters, relays, autoClose: true}) await net.request({filters, relays, autoClose: true})
// Outbox-model author load (resolves the author's write relays automatically). // Outbox-model author load (resolves the author's write relays automatically).
@ -232,7 +240,7 @@ const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]})
const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]}) const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]})
// A loader with different batching, still bound to this app's net context: // A loader with different batching, still bound to this app's net context:
const slowLoad = net.makeLoader({delay: 500, timeout: 5000, threshold: 0.5}) const slowLoad = net.makeLoader({delay: 500, timeout: 5000})
// Negentropy-aware reconciliation (falls back to request/publish when unsupported): // Negentropy-aware reconciliation (falls back to request/publish when unsupported):
await app.use(Sync).pull({relays, filters: [{authors: [pk]}]}) await app.use(Sync).pull({relays, filters: [{authors: [pk]}]})
@ -437,9 +445,9 @@ const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]})
| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` | | `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` |
| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` | | `deriveProfile(pk)` | `app.use(Profiles).one(pk)` |
| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` | | `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` |
| `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) | | `publishThunk({...})` | `app.use(Publisher).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) | | `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) |
| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` | | `load({...})` / `request({...})` | `app.use(Network).loadComplete({...})` / `request({...})` |
| `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) | | `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) |
| `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) | | `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) |
| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` | | `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` |

View file

@ -75,6 +75,8 @@ isText isTopic
| `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. | | `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. |
| `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. | | `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. |
| `Renderer` | Class with `addText`, `addLink`, `addEntityLink`, `addNewlines`, `toString`. | | `Renderer` | Class with `addText`, `addLink`, `addEntityLink`, `addNewlines`, `toString`. |
| `summarize(parsed, getName?)` | Replaces every element that is not prose (entities, links, invoices) with `Text` naming it — "another message", "a link to example.com", "an image". Returns `Parsed[]`, so it composes with `truncate`, `reduceLinks` and either renderer. |
| `defaultSummaryName(parsed)` | The name `summarize` uses for an element its `getName` returned `undefined` for, or `undefined` itself for prose. |
`RenderOptions` fields (all optional when using the convenience functions): `RenderOptions` fields (all optional when using the convenience functions):
@ -123,6 +125,22 @@ const preview = truncate(withGrids, { minLength: 300, maxLength: 500 })
const html = renderAsHtml(preview).toString() const html = renderAsHtml(preview).toString()
``` ```
### Read a message as one line of prose
```typescript
import { parse, ParsedType, renderAsText, summarize } from '@welshman/content'
// A notification body or a speech synthesizer wants each entity named as it reads
const summary = renderAsText(
summarize(parse({ content, tags }), p => {
if (p.type === ParsedType.Profile) {
return profilesByPubkey.get(p.value.pubkey)?.name
}
}),
).toString()
// → 'hodlbod said another message look at an image'
```
### Extract all mentioned pubkeys ### Extract all mentioned pubkeys
```typescript ```typescript
@ -210,7 +228,7 @@ const emojiElements = parsed.filter(isEmoji)
- **`reduceLinks` requires block-level links**: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left as `ParsedLink`. - **`reduceLinks` requires block-level links**: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left as `ParsedLink`.
- **`isImage` is stricter than `urlIsMedia`**: `isImage` only matches `.jpg/.jpeg/.png/.gif/.webp` — it will not match `.mp4` or `.webm`. Use `urlIsMedia` directly if you need to detect video; note that `urlIsMedia` takes a URL string, not a `Parsed` element — usage would be: `urlIsMedia(parsed.value.url.toString())`. - **`isImage` is stricter than `urlIsMedia`**: `isImage` only matches `.jpg/.jpeg/.png/.gif/.webp` — it will not match `.mp4` or `.webm`. Use `urlIsMedia` directly if you need to detect video; note that `urlIsMedia` takes a URL string, not a `Parsed` element — usage would be: `urlIsMedia(parsed.value.url.toString())`.
- **`Renderer.toString()`** is how you get the final string out. `renderAsHtml` and `renderAsText` both return a `Renderer` instance, not a string. - **`Renderer.toString()`** is how you get the final string out. `renderAsHtml` and `renderAsText` both return a `Renderer` instance, not a string.
- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid). - **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid). `summarize` does name it ("some images"), so a summary is not affected.
- **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements. - **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements.
- **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element. - **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element.
- **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches. - **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches.

View file

@ -1,197 +1,350 @@
--- ---
name: welshman-domain name: welshman-domain
description: "Use this skill when working with @welshman/domain: reading or writing a specific nostr event kind, parsing tags, building events to publish, or adding support for a new kind. Provides a typed Reader/Writer pair per kind so you never hand-parse tags." description: "Use this skill when working with @welshman/domain: reading, building, querying, and routing typed nostr events by kind — the Reader/Writer/Query trio (EventReader/EventWriter/EventQuery, ListReader/ListWriter) bound to dependencies through KindFactory.configure → ConfiguredKind, that sits between @welshman/util's raw event types and @welshman/app's data plugins. Covers Profile, NIP-51 lists (follows, mutes, pins, relays, bookmarks, topics, emoji, feeds, blossom), NIP-29 rooms, Flotilla relay/space membership, NIP-89 handlers, NIP-57/75 zaps, notes/deletes/reactions, and content kinds (comment, thread, classified, poll, calendar, report). Use it to parse an event into domain getters, build/edit an event template, build the filters and relays that fetch a kind's events, resolve publish relays via the RelaySelection routing DSL (routes/forceRoutes/requiresRelays/scenario), handle NIP-44 private-list encryption, or migrate from the old Reader/Builder core (fromEvent/factory/toTemplate/toEvent/EventBuilder/ListBuilder/XBuilder/commandFromBuilder)."
--- ---
# welshman/domain — Typed Readers and Writers per Kind # welshman/domain — Typed Readers, Writers & Routing for Nostr Kinds
`@welshman/domain` replaces ad-hoc tag digging. Every supported kind gets a **Reader** (typed ## Overview
accessors over an event) and a **Writer** (a builder that renders a new event), paired in a
`KindFactory`.
**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the `@welshman/domain` translates nostr events to and from typed domain objects, and works out where to publish and fetch them. For each event kind it ships a matched trio:
reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader,
use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`. - a **Reader** — a read-only view over a `TrustedEvent` with synchronous getters (`profile.name()`, `followList.pubkeys()`),
- a **Writer** — a mutable, chainable producer of an `EventTemplate` plus the relays to publish it to (`writer.renderTemplate()` / `writer.render()`), and
- a **Query** — a mutable, chainable producer of the `Filter`s that fetch the kind's events and the relays to request them from (`query.renderFilters()` / `query.render()`).
A kind is packaged as a **`KindFactory`**; you bind it to app dependencies once with `factory.configure(context)`, which returns a **`ConfiguredKind`** that mints readers, writers, and queries. Routing runs through the `@welshman/util` `RelaySelection` DSL and a `Resolver` supplied in the context.
It sits one layer above `@welshman/util` (raw `TrustedEvent`/`EventTemplate` types, tag getters, kind constants, the routing DSL) and one layer below `@welshman/app` (whose `Domain` plugin supplies the context and whose data plugins use these readers as their `eventToItem`). The package holds no stores, no network, no globals — dependencies arrive through the `KindContext`.
This replaces the old free-function helpers that used to live in `@welshman/util` (`makeProfile`/`readProfile`, `makeList`/`readList`, `readHandlers`, `Encryptable`, `makeRoomMetaEvent`/`makeRoomEditEvent`) **and** the earlier Reader/Builder core (`EventBuilder`/`ListBuilder`, `X.fromEvent`, `Kind.factory`, `builder.toTemplate`/`toEvent`). See the migration table below.
## Installation ## Installation
```bash ```bash
npm i @welshman/domain npm install @welshman/domain
# or
pnpm add @welshman/domain
``` ```
## The three pieces Peer deps: `@welshman/lib`, `@welshman/util`, `@welshman/signer`, `@welshman/net`, `@welshman/feeds`, and `nostr-tools`.
## Core mental model
1. **A kind is a `KindFactory`; bind it once with `configure`.** Each exported kind constant is `new KindFactory({kind, reader, writer, query})` — e.g. `export const Note = new KindFactory({kind: NOTE, reader: NoteReader, writer: NoteWriter, query: NoteQuery})`. Call `Note.configure(context)` to get a `ConfiguredKind` carrying the app's `resolver` and an optional `signer`, which is all `KindContext` holds. In an app you never call `configure` yourself — `@welshman/app`'s `Domain` plugin does it and memoizes the result.
2. **Readers wrap an event; Writers produce a template + relays; Queries produce filters + relays.** `configured.reader(event)` builds a Reader (unparsed) and `.parse()` populates it; `configured.writer(reader?)` builds a Writer, optionally seeded from a Reader (the edit flow); `configured.query()` builds a Query.
3. **Reading is sync unless the kind decrypts; getters are always sync.** You enter through `configured.reader(event).parse()`, which validates `event.kind` (throwing `Expected a kind X event, got kind Y`) and parses. `parse()` returns the reader for kinds that extend `EventReader` and a promise of it for kinds that extend `AsyncEventReader` — only the six private-tag lists and `AppData`, which have to decrypt. `await` works for both, and `Parsed<R>` is the type of whichever one a reader yields.
4. **Building is chainable; output is async.** Setters return `this`; you finish with `await w.renderTemplate()` (an `EventTemplate`), `await w.relays()` (publish urls), or `await w.render()` (both). None of these take arguments — the signer, resolver, and repository come from the context bound at `configure`.
5. **The signer is optional and lazy.** Most kinds ignore it. Only NIP-51 lists need it — to *decrypt* private tags on read, and to *encrypt* them (NIP-44, self-encrypted) on build. The app supplies it as a lazy getter so auth policies can swap it after `configure`.
6. **Round-trips preserve unknown tags.** Seed a Writer from a Reader and any tags the class doesn't model are carried through verbatim into the rebuilt template.
## Reading an event
Go through a `ConfiguredKind`. `configured.reader` validates `event.kind === reader.kind` and builds the reader; chain `parse()` to populate it.
```typescript ```typescript
import {KindFactory} from "@welshman/domain" import {Profile, FollowList} from "@welshman/domain"
KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta` // Bind dependencies once (the app's Domain plugin does this for you):
.configure(ctx) // binds a resolver/signer -> ConfiguredKind const profiles = Profile.configure(context) // context: KindContext
.reader(event) // -> Reader instance (call .parse() before use) const follows = FollowList.configure(context) // context.signer decrypts private tags
.writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it
// kind 0 parses without IO, so there is nothing to await:
const profile = profiles.reader(event).parse() // validates + parse()
// follow lists are public, but a mute list would decrypt — and then `parse()`
// returns a promise the type makes you await:
const list = follows.reader(event).parse()
const mutes = await MuteList.configure(context).reader(event).parse()
``` ```
You rarely call `configure` directly — the `Domain` plugin does it for you. `ConfiguredKind.reader` / `.writer` / `.query` are instance arrow-function properties, so you can destructure them (`const {reader} = Profile.configure(ctx)`).
## Usage through an app Common base getters available on every reader (all synchronous): `id()`, `author()`, `content()`, `tags()`, `createdAt()`, `identifier()` (d-tag), `address()` (`kind:pubkey:d`), `room()` (NIP-29 h-tag), `protect()` (has `["-"]`), `expiration()`, `contentWarning()` (has a NIP-36 `content-warning` tag), `contentWarningReason()`, `client()` (the NIP-89 `client` tag as `{name, address?, relay?}`). Each kind adds its own — e.g. `profile.name()`, `profile.display()`, `followList.pubkeys()`, `followList.includes(pk)`.
The getters for tags any kind can carry delegate to standalone functions in `src/behaviors/` — `isProtected`, `getExpiration`, `getContentWarning`, `getClient`, `getEmojis`, `getZapSplits` (plus `splitZapAmount`). Use those on a bare event: `reader()` throws on a kind mismatch, so a cross-kind check can't go through a reader.
## Building / editing an event
```typescript
import {Profile, FollowList} from "@welshman/domain"
// Build from scratch:
const {writer} = Profile.configure(context)
const template = await writer()
.setName("alice")
.setAbout("hi")
.renderTemplate() // EventTemplate {kind, content, tags}
// Edit an existing event (seed the writer from a reader):
const reader = FollowList.configure(context).reader(event).parse()
const w = FollowList.configure(context).writer(reader) // seeded from reader
.follow(pubkey)
const {event: template2, relays} = await w.render() // template + publish urls
```
- Construct empty (`configured.writer()`) or from a reader (`configured.writer(reader)`) for the edit flow.
- Base behavior setters (chainable): `setContent`, `setRoom(url, room)`/`clearRoom` (h-tag + forced routes), `forceRoutes(...routes)`/`clearForcedRoutes`, `setProtected(bool)`, `setExpiration`/`clearExpiration`, `setContentWarning(reason?)`/`clearContentWarning`, `setClient(name, address?)`/`clearClient`, `setIdentifier`/`clearIdentifier` (d-tag, defaults to a random id), `addTags`, `keepTags(pred)`, `dropTags(pred)`. Shared tag/hint helpers: `tagPubkey(pubkey, petname?)`, `addQuote(event, relay?)`, `addZapSplit(pubkey, split?)`. Each kind adds its own setters.
- Output methods (all async, no arguments):
- `renderTemplate()` → `EventTemplate` — runs `validate()`, then renders tags and content. A tag that carries a relay hint fills its hint slot through the protected `hint(...routes)` helper, which resolves routes to a single url; `renderTags()` awaits every in-flight hint before returning. List content is encrypted here.
- `scenario()` → `RelayScenario` (chainable: `.limit()`, `.policy()`, `.allowLocal()`, …); `relays()` → `string[]` (the resolved publish urls).
- `render()` → `{event: EventTemplate; relays: string[]}` — `renderTemplate()` and `relays()` together.
There is **no** `toEvent`/`toRumor`/`toTemplate` on the writer — the caller signs the template. In an app you hand the writer to `Domain.command(writer)` (which calls `render()` and wraps it in a `Command`). Directly, you sign the `renderTemplate()` output:
```typescript
import {stamp} from "@welshman/util"
const signed = await signer.sign(stamp(await writer.renderTemplate())) // SignedEvent
```
**Round-trip / extra-tag passthrough.** When a writer is seeded from a reader, every tag in `event.tags` starts in `extraTags`. The base constructor lifts out `h`/`-`/`expiration`/`content-warning`/`client`/`d` (into `roomTag`/`protectTag`/`expirationTag`/`contentWarningTag`/`clientTag`/`identifierTag`); each subclass lifts the tags it models. Whatever is left is re-emitted unchanged, so unmodeled tags survive an edit. Tag assembly is `[...renderBehaviorTags(), ...renderDomainTags(), ...extraTags]`.
## Querying a kind's events
```typescript
import {Note, Comment} from "@welshman/domain"
// Filters + the relays to request them from:
const {filters, relays} = await Note.configure(context)
.query()
.setAuthors([pubkey])
.setSince(since)
.setLimit(50)
.render()
// Kind-specific queries add methods for the relationships the kind models:
const thread = await Comment.configure(context).query().forRoot(event).render()
```
- Every filter field has a set/add/remove/clear group (`setIds`/`addIds`/`removeIds`/`clearIds`, same for `Authors`), or set/clear for the scalars (`setSince`, `setUntil`, `setLimit`, `setSearch`). Tag filters go through `setTag(key, values)`/`addTag`/`removeTag`/`clearTag`/`clearTags`, with or without the `#` prefix. The kind itself is fixed by the factory.
- A field left alone is unconstrained; one set to an explicitly empty array matches nothing. Adding no values is a no-op.
- **`renderRoutes()` is abstract, so every kind states where its events live.** Two protected helpers cover the common shapes: `authorRoutes()` (the queried authors' outboxes) and `mentionRoutes()` (inboxes of the pubkeys a `#p` filter names). Content kinds use both; author-scoped kinds (lists, app data) use `authorRoutes()` alone; indexed kinds (`Profile`, `FollowList`, `RelayList`, `MessagingRelayList`) add `indexers()`; the 20 `requiresRelays` room/relay-management kinds return `[]`; `DirectMessage` returns `[userMessaging()]`.
- A query with nothing to route on resolves to **no relays**; there is no fallback to the user's own. `setRoutes(routes)` replaces the rendered routes (`[relay(url)]`, `[userInbox()]`, …), `addRoutes(routes)` requests those alongside them, and `setRoom(url, room)` scopes a NIP-29 query to one relay's room.
- Output methods mirror the writer's: `renderFilters()` → `Filter[]`, `relays()`/`scenario()` → the request relays, `render()` → both.
- Past `renderRoutes`, most query subclasses are empty. A kind that models a relationship overrides `renderDomainFilters()`, merged onto the base filter, one filter per variant. `CommentQuery.forRoot(event)`/`forParent(event)` emit a filter each for the `E`/`e` id and `A`/`a` address reference forms, since a relay ANDs a filter's fields, and route to each target author's inbox, where comments p-tagged to that author are delivered.
## Routing: routes / forceRoutes / requiresRelays
Publishing targets come from the `@welshman/util` `RelaySelection` DSL (`outbox`, `inbox`, `inboxes`, `userOutbox`, `seen`, `relay`, `relays`, `indexers`, …). A writer resolves them through `context.resolver`.
- **Default routing** (`EventWriter.renderRoutes`): `[userOutbox(), ...inboxes(pTaggedPubkeys, 0.5)]` — deliver to the author's write relays (weight 1) and to every p-tagged pubkey's read relays (weight 0.5). Most kinds use this (`NoteWriter`, …).
- **`forceRoutes(...routes)`** sets `forcedRoutes`; when non-empty, `scenario()` publishes **only** there, bypassing `renderRoutes()`. It takes routes rather than urls, so `forceRoutes(userInbox())` pins an event to the user's read relays without resolving them first. `setRoom(url, room)` sets `forcedRoutes=[relay(url)]` **and** the `h` tag (NIP-29 room events); `clearForcedRoutes()`/`clearRoom()` undo it.
- **`requiresRelays`** (a readonly `true` on a subclass) makes `validate()` demand `forcedRoutes` — throwing `A kind N event must publish to explicit relays (via setRoom or forceRoutes)`. The 20 kinds that set it are all NIP-29 room ops/state and relay-management ops/state: `RoomCreate`, `RoomEdit`, `RoomDelete`, `RoomDeleteEvent`, `RoomJoin`, `RoomLeave`, `RoomAddMember`, `RoomRemoveMember`, `RoomMembers`, `RoomAdmins`, `RoomMeta`, `RoomPins`, `RoomUpdatePins`, `RoomCreatePermission`, `RelayJoin`, `RelayLeave`, `RelayInvite`, `RelayAddMember`, `RelayRemoveMember`, `RelayRole`, `RelayMembers`. (`RoomCreate` and `RoomJoin` additionally require a `roomTag` — call `setRoom`.)
- **Per-kind overrides.** Some writers replace `renderRoutes()`: `FollowListWriter`/`MuteListWriter`/`ReportWriter` publish to `[userOutbox()]` only (p-tags are data, not recipients); `RelayListWriter` adds `indexers()` and notifies every relay added to or removed from the list; `DeleteWriter` adds each deleted event's `seen` relays (and requires an `e`/`a` tag).
The DSL constructors `relays(urls)` and `inboxes(pubkeys)` return **arrays** (spread with `...`); the others return a single `RelaySelection`. Note `relay(url)`/`relays(urls)` replaced the old `relayHint`/`relayHints`.
## Async & signer notes
- **Async surface:** all of `renderTemplate`/`render`/`scenario`/`relays`/`renderTags` are async. `reader(event).parse()` is async only for the kinds that decrypt (`ListReader` subclasses, `AppData`); every getter and every setter is synchronous.
- **Reading private list tags:** a `ListReader` only surfaces `privateTags` when the context carries the **author's own** signer (it decrypts NIP-44 content only when `signer.getPubkey() === event.pubkey`). Decryption failures are swallowed — `decrypted` stays `false` and private tags stay empty.
- **Writing private list tags:** `ListWriter.buildContent` is where encryption happens. If there are private tags it requires a signer and NIP-44-encrypts them to the author's own pubkey (`A signer is required to encrypt private tags`). If the source was never decrypted, the original ciphertext is preserved untouched (so you don't clobber tags you couldn't see).
- **`renderTemplate()`** needs a signer only for list kinds with non-empty private tags. All other kinds ignore it. Signing (`signer.sign(stamp(...))`) always needs a signer.
- **`d`-tag required** (base `validate` throws otherwise) for parameterized-replaceable kinds: `RelaySet` (30002), `BadgeDefinition` (30009), `Pinboard` (30067), `Classified` (30402), `Feed` (31890), `DateEvent` (31922), `TimeEvent` (31923), `CalendarRsvp` (31925), `HandlerRecommendation` (31989), `Handler` (31990), `RoomMeta` (39000), `RoomAdmins` (39001), `RoomMembers` (39002), `Pin` (39067). Call `setIdentifier()`. `Pin` additionally requires a content reference (`e`/`a`/`i` tag) — without a unique `d` tag per pin, every pin from the same author would collide at the same address, since kind 39067 is itself addressable.
- **Explicit relays required** (`requiresRelays`, see above): all NIP-29 room and relay-management ops. Call `setRoom(url, room)` (which sets both the `h` tag and the forced route) or `forceRoutes(...routes)`.
## Kind classes
Each row: kind# — NIP — Reader / Writer.
### Profile
| Kind | NIP | Reader / Writer |
|---|---|---|
| 0 | NIP-01 | `ProfileReader` / `ProfileWriter` (factory `Profile`) |
`ProfileReader`: `name`, `nip05`, `lnurl`, `about`, `banner`, `picture`, `website`, `display(fallback?)`. Writer: `update`, `setName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`. (Also exports `parseLnUrl`, `displayPubkey`.)
### Core notes / deletes / reactions
| Kind | NIP | Reader / Writer |
|---|---|---|
| 1 | NIP-01 / NIP-10 | `NoteReader` / `NoteWriter` (factory `Note`) |
| 5 | NIP-09 | `DeleteReader` / `DeleteWriter` (factory `Delete`) |
| 7 | NIP-25 | `ReactionReader` / `ReactionWriter` (factory `Reaction`) |
| 14 | NIP-17 | `DirectMessageReader` / `DirectMessageWriter` (factory `DirectMessage`) |
| 9802 | NIP-84 | `HighlightReader` / `HighlightWriter` (factory `Highlight`) |
`DirectMessageReader`: `recipients()`, `subject()`, `parentId()`; writer `addRecipient`/`removeRecipient`/`setSubject`/`setParent`. The **writer** renders **no** routes (`[]`) — a NIP-17 message is published as one gift wrap per recipient, each to its own relays, so the caller fans it out (`app.use(Wraps).publish`). The **query** routes to `[userMessaging()]`.
`HighlightReader`: `sources()`, `attributions()`, `mentions()`, `sourceContext()`, `comment()`, `references()`, `topics()`; writer `setSourceEvent`/`setSourceExternal`/`setSourceReference`, `addAttribution`/`removeAttribution`, `addMention`, `setSourceContext`/`clearSourceContext`, `setComment`/`clearComment`, `setTopics`.
`NoteWriter.setParent(event)` — NIP-10 reply threading (p-tags the parent's participants, e/a-tags the parent and thread root with markers + relay hints). `DeleteReader`: `ids()`, `addresses()`, `kinds()`, `reason()`; `DeleteWriter`: `addEvent(event)`, `setReason(reason)` (routes to the deleted events' `seen` relays; requires an `e`/`a` tag).
### Lists (ListReader/ListWriter — public/private split, NIP-44 encryption)
| Kind | NIP | Reader / Writer |
|---|---|---|
| 3 | NIP-02 | `FollowListReader` / `FollowListWriter` |
| 10000 | NIP-51 | `MuteListReader` / `MuteListWriter` |
| 10001 | NIP-51 | `PinListReader` / `PinListWriter` |
| 10002 | NIP-65 | `RelayListReader` / `RelayListWriter` |
| 10003 | NIP-51 | `BookmarkListReader` / `BookmarkListWriter` |
| 10004 | NIP-51 | `CommunityListReader` / `CommunityListWriter` |
| 10006 | NIP-51 | `BlockedRelayListReader` / `BlockedRelayListWriter` |
| 10007 | NIP-51 | `SearchRelayListReader` / `SearchRelayListWriter` |
| 10009 | NIP-51 | `RoomListReader` / `RoomListWriter` |
| 10014 | NIP-51 | `FeedListReader` / `FeedListWriter` |
| 10015 | NIP-51 | `TopicListReader` / `TopicListWriter` |
| 10030 | NIP-51 | `EmojiListReader` / `EmojiListWriter` |
| 10050 | NIP-17 | `MessagingRelayListReader` / `MessagingRelayListWriter` |
| 10063 | Blossom BUD-03 | `BlossomServerListReader` / `BlossomServerListWriter` |
| 30002 | NIP-51 | `RelaySetReader` / `RelaySetWriter` |
`ListWriter` mutators (public/private split): `addPublic`/`addPrivate`, `keepPublic`/`keepPrivate`/`keepTags`, `dropPublic`/`dropPrivate`/`dropTags`. Each kind also exposes intent-named helpers, e.g. `FollowListWriter.follow(pubkey, relayHint?, petname?)`/`unfollow`, `MuteListWriter.mutePublicly`/`mutePrivately`/`unmute` (its reader has `pubkeys()`, `words()`, `topics()`, `ids()`, `addresses()` over public and decrypted private tags), `RelayListWriter.addReadUrl`/`addWriteUrl`/`removeReadUrl`/`removeWriteUrl`/`setReadUrls`/`setWriteUrls`/`setTags`, `RoomListWriter.addRoom`/`removeRoom`/`addRelay`/`removeRelay`/`setRelays`. (Note `FollowList` is a plain `EventWriter`, not a `ListWriter` — follows are public.)
### Rooms (NIP-29)
Room ops are scoped by the `h` tag and must publish to explicit relays — use `setRoom(url, room)`. Metadata kinds 39000–39002 are addressable per room.
| Kind | NIP | Reader / Writer |
|---|---|---|
| 9000 | NIP-29 | `RoomAddMemberReader` / `RoomAddMemberWriter` |
| 9001 | NIP-29 | `RoomRemoveMemberReader` / `RoomRemoveMemberWriter` |
| 9002 | NIP-29 | `RoomEditReader` / `RoomEditWriter` |
| 9007 | NIP-29 | `RoomCreateReader` / `RoomCreateWriter` |
| 9005 | NIP-29 | `RoomDeleteEventReader` / `RoomDeleteEventWriter` |
| 9008 | NIP-29 | `RoomDeleteReader` / `RoomDeleteWriter` |
| 9021 | NIP-29 | `RoomJoinReader` / `RoomJoinWriter` |
| 9022 | NIP-29 | `RoomLeaveReader` / `RoomLeaveWriter` |
| 19004 | NIP-29 / Flotilla | `RoomCreatePermissionReader` / `RoomCreatePermissionWriter` |
| 39000 | NIP-29 | `RoomMetaReader` / `RoomMetaWriter` |
| 39001 | NIP-29 | `RoomAdminsReader` / `RoomAdminsWriter` |
| 39002 | NIP-29 | `RoomMembersReader` / `RoomMembersWriter` |
| 9010 | NIP-29 | `RoomUpdatePinsReader` / `RoomUpdatePinsWriter` — the pin op |
| 39005 | NIP-29 | `RoomPinsReader` / `RoomPinsWriter` — the relay-signed pin snapshot |
`RoomMetaReader`: `name`, `about`, `picture`, `pictureMeta`, `isClosed`/`isHidden`/`isPrivate`/`isRestricted`/`hasLivekit`; writer `setName`/`setAbout`/`setPicture`/`setClosed`/`setHidden`/`setPrivate`/`setRestricted`/`setLivekit`. `RoomJoinReader`: `claim()`, `reason()` (free-text `content`); writer `setClaim`/`setReason`. `RoomAddMemberWriter.addPubkey`. `RoomAdminsReader`: `pubkeys()`, `rolesFor(pk)`, `rolesByPubkey()` over `["p", pubkey, ...roles]` tags; writer `addPubkey(pk, roles?)` (omitting `roles` keeps the admin's existing ones), `removePubkey`, `setPubkeys` (keeps retained admins' roles).
### Relay membership (Flotilla "spaces" — relay-level, NIP-29-adjacent)
| Kind | NIP | Reader / Writer |
|---|---|---|
| 8000 | Flotilla | `RelayAddMemberReader` / `RelayAddMemberWriter` |
| 8001 | Flotilla | `RelayRemoveMemberReader` / `RelayRemoveMemberWriter` |
| — | Flotilla | `RelayRoleReader` / `RelayRoleWriter` |
| 13534 | Flotilla | `RelayMembersReader` / `RelayMembersWriter` |
| 28934 | Flotilla | `RelayJoinReader` / `RelayJoinWriter` |
| 28935 | NIP-29 | `RelayInviteReader` / `RelayInviteWriter` |
| 28936 | Flotilla | `RelayLeaveReader` / `RelayLeaveWriter` |
`RelayMembersReader`: `pubkeys()`, `isMember(pk)`, `rolesFor(pk)`, `rolesByPubkey()` over `["member", pubkey, ...roleIds]` tags; writer `addPubkey(pk, roles?)` (omitting `roles` keeps existing ones)/`removePubkey`/`setPubkeys` (keeps retained members' roles) (its constructor calls `setProtected(true)` per NIP-43). All of these set `requiresRelays` — publish with `forceRoutes(relay(url))` or `setRoom`.
### Handlers (NIP-89)
| Kind | NIP | Reader / Writer |
|---|---|---|
| 31989 | NIP-89 | `HandlerRecommendationReader` / `HandlerRecommendationWriter` |
| 31990 | NIP-89 | `HandlerReader` / `HandlerWriter` |
`HandlerReader`: JSON content → `values: HandlerMeta`; getters `name`, `about`, `picture`, `website`, `lud16`, `nip05`, `kinds()`; writer `setName`/…/`setKinds(number[])`. Exports type `HandlerMeta`.
### Zaps (NIP-57 / NIP-75)
| Kind | NIP | Reader / Writer |
|---|---|---|
| 9041 | NIP-75 | `ZapGoalReader` / `ZapGoalWriter` |
| 9734 | NIP-57 | `ZapRequestReader` / `ZapRequestWriter` |
| 9735 | NIP-57 | `ZapReceiptReader` / `ZapReceiptWriter` |
`ZapRequestReader`: `amount`, `lnurl`, `recipient`, `eventId`, `urls` (the comment is the base `content()`). `ZapReceiptReader`: `bolt11`, `invoiceAmount`, `request`, `sender`, `recipient`, `eventId`, `comment`, `preimage`, plus `verify(zapper)`.
### Content
| Kind | NIP | Reader / Writer |
|---|---|---|
| 11 | NIP-7D | `ThreadReader` / `ThreadWriter` |
| 20 | NIP-68 | `PictureReader` / `PictureWriter` |
| 1018 | NIP-88 | `PollResponseReader` / `PollResponseWriter` |
| 1068 | NIP-88 | `PollReader` / `PollWriter` |
| 1111 | NIP-22 | `CommentReader` / `CommentWriter` |
| 1984 | NIP-56 | `ReportReader` / `ReportWriter` |
| 30067 | Pinboards | `PinboardReader` / `PinboardWriter` |
| 30402 | NIP-99 | `ClassifiedReader` / `ClassifiedWriter` |
| 30078 | NIP-78 | `AppDataReader` / `AppDataWriter` |
| 31890 | NIP-51 | `FeedReader` / `FeedWriter` |
| 31922 | NIP-52 | `DateEventReader` / `DateEventWriter` |
| 31923 | NIP-52 | `TimeEventReader` / `TimeEventWriter` |
| 31925 | NIP-52 | `CalendarRsvpReader` / `CalendarRsvpWriter` |
| 39067 | Pinboards | `PinReader` / `PinWriter` |
| 30023 | NIP-23 | `ArticleReader` / `ArticleWriter` (factory `Article`) |
| 31992 | slash commands | `CommandReader` / `CommandWriter` (factory `Command`) |
`CommentReader`: `root()`/`parent()`; writer `setRoot`/`setParent`/`setRootFromEvent`/`setParentFromEvent`, plus `replyTo(event)`, which parents on `event` and roots on the thread it is in — the root tags of a comment, `event` itself for any other kind. `PollReader`: `title`, `options`, `pollType`, `endsAt`, `isClosed`, `urls`, plus `results(responses)`; writer `addOption`, `setPollType`, `setEndsAt`. `ReportWriter`: `setPubkey`/`setEventId`/`setReason` (routes to `[userOutbox()]`). `DateEventReader` (31922): the all-day sibling of `TimeEvent`, with `start`/`end` as `YYYY-MM-DD` strings and `end` exclusive; writer `setTitle`/`setLocation`/`setStart`/`setEnd`, deriving the same `D` day-bucket tags (the start day alone when there is no end). `CalendarRsvpReader` (31925): `calendarEvent()` (the event's address), `calendarEventId()`, `status()`, `freebusy()`; writer `setCalendarEvent(address, relay?)`/`setCalendarEventFromEvent(event, relay?)` (which also e-tags and p-tags the organizer; a given `relay` is the hint for `a` and `e`, otherwise the `e` hint comes from the organizer's outbox), `setStatus`/`setFreebusy`/`clearFreebusy`, where declining clears free/busy and `validate()` requires an event and a status; query `forCalendarEvent(address)`, which asks the organizer's inbox. `PictureReader` (20): `title`, `topics()`, `location`, `geohash`, plus the shared `imeta()`; writer `setTitle`/`setTopics`/`setLocation`/`setGeohash` and the shared `addImeta`/`removeImeta`. It derives the top-level `x` and `m` tags from its attached images, and `validate()` requires at least one image. Exported types: `CommentRef`, `ClassifiedPrice`, `PollType`, `PollOption`, `PollResult`, `PinReference`, `RsvpStatus`, `RsvpFreebusy`.
`ArticleReader` (long-form, 30023): `title`, `summary`, `image`, `publishedAt`, `topics()`; writer `setTitle`/`setSummary`/`setImage`/`setPublishedAt`/`setTopics`.
`CommandReader` (31992): `command`, `title`, `description`, `args()`, `scopes()`, plus `matches(target: CommandScopeTarget)`; writer `setCommand`/`setTitle`/`setDescription`/`setArgs`/`setScopes`. The invocation grammar itself (parsing/rendering `/command arg…`) lives in `@welshman/util`'s `Command.ts`, so non-domain callers can use it.
`RoomPinsReader`/`RoomUpdatePinsReader`: `pins()`, `ids()`, `addresses()` (`RoomPinsReader` adds `isPinned`); writer `setPins`. Both set `requiresRelays`.
`PinboardReader` (30067): `title`, `description`, `image`, `topics()`, `collaborative()`; writer `setTitle`/`setDescription`/`setImage`/`setTopics`/`setCollaborative`. `PinReader` (39067): `boards()`, `isProfilePin()`, `reference()` (a `PinReference` discriminated union), `title`, `topics()`; writer `addBoard`/`removeBoard`, `setEvent`/`setAddress`/`setExternal`, `setTitle`/`setTopics`.
### Badges (NIP-58)
| Kind | NIP | Reader / Writer |
|---|---|---|
| 8 | NIP-58 | `BadgeAwardReader` / `BadgeAwardWriter` |
| 30008 | NIP-58 | `ProfileBadgesReader` / `ProfileBadgesWriter` |
| 30009 | NIP-58 | `BadgeDefinitionReader` / `BadgeDefinitionWriter` |
`BadgeDefinitionReader` (30009): `name`, `description`, `image()`, `thumbs()` (both `BadgeImage`, `{url, dim?}`); writer `setName`/`setDescription`/`setImage`/`setThumbs`, and `setIdentifier()` for the badge's slug. `BadgeAwardReader` (8): `badge()` (the definition's address), `awardees()`; writer `setBadge`/`addAwardee`/`removeAwardee`, and `validate()` requires both a badge and an awardee. `ProfileBadgesReader` (30008): `badges()` (a `ProfileBadge[]` of `{address, awardId, relay?}` read from consecutive `a`/`e` pairs, in display order), `includes(address)`; writer `setBadges`/`addBadge`/`removeBadge`. Its `d` tag is fixed at `profile_badges` by the writer, so it never needs `setIdentifier()`. Exported types: `BadgeImage`, `ProfileBadge`.
## Using it from @welshman/app
`@welshman/app`'s `Domain` plugin binds the app's dependencies (resolver from the `Router` plugin, repository, and a lazy signer getter) and memoizes one `ConfiguredKind` per factory:
```typescript ```typescript
import {Domain} from "@welshman/app" import {Domain} from "@welshman/app"
import {Note, Profile} from "@welshman/domain" import {FollowList, Note} from "@welshman/domain"
// Read: returns a parsed reader, ready to use // read side (event decoder for a data plugin):
const note = app.use(Domain).reader(Note)(event) eventToItem: app.use(Domain).reader(Note) // ConfiguredKind.reader
note.content() // typed accessors, no tag digging // fetch side — query → filters + relays:
note.author() const {filters, relays} = await app.use(Domain).query(Note).setAuthors([pubkey]).render()
note.createdAt()
// Write: build, then wrap in a Command to publish // mutation side — writer → Command → publish:
const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey) const reader = existingReader // from a prior read, or undefined
const command = await app.use(Domain).command(writer) const writer = app.use(Domain).writer(FollowList, reader).follow(pubkey)
const command = await app.use(Domain).command(writer) // render() + wrap
await command.publish().waitForError() command.publish() // or .publishToRelays(urls)
// Edit an existing event: seed the writer with its reader
const profile = app.use(Domain).reader(Profile)(profileEvent)
const edit = app.use(Domain).writer(Profile, profile).setName("new name")
``` ```
## Sync vs async readers `Domain.command(writer)` requires a signed-in user, calls `writer.render()`, and returns a `Command` (`.publish()` / `.publishToRelays(urls)`). This replaces the old `Router.commandFromBuilder(builder)`. The `Router` plugin's `resolver` (a `Resolver`) is what dereferences every route to concrete relay urls.
Some kinds decrypt on parse (encrypted lists, app data), so `parse()` is async for them: ## Gotchas
- `EventReader.parse(): this` - **Enter through a `ConfiguredKind`.** `Profile.configure(ctx).reader(event).parse()` to read, `configure(ctx).writer()` to build. In an app, `app.use(Domain).reader(Profile)` and `.writer(Profile)`.
- `AsyncEventReader.parse(): Promise<this>` - **The writer never signs.** `renderTemplate()` gives an `EventTemplate` you sign yourself (`signer.sign(stamp(await writer.renderTemplate()))`), or hand the writer to `Domain.command`. `renderTemplate`/`scenario`/`relays`/`render` take no arguments; dependencies come from the context.
- **Private list tags need the author's signer in the context.** With no signer (or someone else's) a `ListReader` yields only public tags; `decrypted` stays `false`. Bind the author's own signer to see private entries.
- **Don't clobber undecryptable lists.** If you edit a list you couldn't decrypt and try to write private tags, `validate()` throws `Unable to modify list when decryption was not performed`. Editing only public tags is fine — the original ciphertext is preserved.
- **Room / relay-management kinds need explicit relays.** They set `requiresRelays`, so `render()` throws unless you called `setRoom(url, room)` or `forceRoutes(...routes)`. `RoomCreate`/`RoomJoin` also require the `h` tag (use `setRoom`).
- **Parameterized-replaceable kinds throw without a `d` tag** — call `setIdentifier()` (or let it default to a random id).
- **`RoomJoin`/`RelayJoin`/`RelayInvite` read the invite code via `claim()`.**
`Parsed<R> = ReturnType<R["parse"]>` captures which one you get. `app.use(Domain).reader(F)` ## OLD → NEW migration
returns `Parsed<R>` — the reader itself for sync kinds, a promise for async ones. That's exactly
what `EventToItem` accepts, so collections keep their synchronous path where the kind allows one.
```typescript | Old API | New API |
const note = app.use(Domain).reader(Note)(event) // NoteReader
const list = await app.use(Domain).reader(MuteList)(event) // MuteListReader (decrypts)
```
## Reader base API
Every reader inherits from `BaseEventReader`:
| Method | Returns |
|---|---| |---|---|
| `id()` | event id | | `new Kind({reader, builder, router})` | `new KindFactory({kind, reader, writer, query})` |
| `author()` | pubkey | | `Kind` class | `KindFactory` (+ `ConfiguredKind` after `.configure`) |
| `content()` | content string | | `EventBuilder` (base) / `XBuilder` | `EventWriter` / `XWriter` |
| `tags()` | raw tags (prefer a typed getter when one exists) | | `ListBuilder` | `ListWriter` |
| `createdAt()` | timestamp | | `X.fromEvent(event)` / `Kind.factory(event)` / `Kind.read(event)` | `factory.configure(ctx).reader(event).parse()` (validates kind, then parses — await only for lists / app data) |
| `identifier()` | `d` tag | | `Kind.builder(...)` / `new XBuilder(...)` | `factory.configure(ctx).writer(reader?)` |
| `address()` | `kind:pubkey:d` | | `builder.toTemplate()` | `writer.renderTemplate()` → `Promise<EventTemplate>` |
| `room()` | `h` tag | | `builder.toEvent(signer)` | `signer.sign(stamp(await writer.renderTemplate()))` |
| `protect()` | whether the event carries `-` (NIP-70) | | `builder.toRumor(signer)` | `prep(await writer.renderTemplate(), await signer.getPubkey())` |
| `expiration()` | `expiration` tag as a number | | `Router.commandFromBuilder(builder)` | `app.use(Domain).command(writer)` |
| `emojis()` | parsed `emoji` tags | | `parse(signer)` | `parse()` (reads `def.context.signer`) |
| `zapSplits()` | parsed `zap` tags | | `builder.finalize(context)` | `writer.render()` (no arg; context bound at `configure`) |
| standalone `resolve(...)` | `new Resolver(routeResolver, options)` → `.scenario`/`.relays`/`.relay` |
Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`, | `relayHint(url)` / `relayHints(urls)` | `relay(url)` / `relays(urls)` |
`RelayMembersReader.isMember(pubkey)`, and so on. | — (new) | `inboxes(pubkeys, weight?)`, `Resolver`, `RelayScenario`, `KindContext` |
## Writer base API
Writers are chainable and validate on render:
```typescript
writer
.setContent(content)
.addTags(...tags)
.dropTags(pred) // also keepTags(pred)
.setIdentifier(d) // defaults to a random id
.setRoom(url, h) // NIP-29 `h` tag + forced relay
.setProtected(true) // NIP-70 `-`
.setExpiration(ts)
.addMention(pubkey)
.addQuote(event)
.addZapSplit(pubkey, split)
.addEmoji(shortcode, url)
.forceRelays(...urls) // bypass routing for relay-scoped kinds
```
`readonly requiresRelays` marks kinds that are meaningless without an explicit relay (NIP-29 room
ops, relay membership). Subclasses override `validate()` to enforce kind-specific invariants —
e.g. `RoomUpdatePins` throws without a room, `ZapGoal` requires an amount and at least one relay.
## Display helpers
A few kinds export display helpers alongside their reader:
```typescript
import {displayPubkey} from "@welshman/domain"
profile.display("Anonymous") // ProfileReader: name/display_name, or the fallback
displayPubkey(pubkey) // 'npub1abc...xyz'
```
## Adding a new kind
1. Add the kind constant to `@welshman/util`'s `Kinds.ts`.
2. Create `packages/domain/src/kinds/YourKind.ts` with a Reader, a Writer, and the factory:
```typescript
import {uniq, spec} from "@welshman/lib"
import {YOUR_KIND, hexTags, tagValues} from "@welshman/util"
import {EventReader} from "../core/EventReader.js"
import {EventWriter} from "../core/EventWriter.js"
import {KindFactory} from "../core/Kind.js"
export class YourKindReader extends EventReader {
pubkeys() {
return uniq(tagValues(hexTags("p"), this.event.tags))
}
}
export class YourKindWriter extends EventWriter<YourKindReader> {
readonly requiresRelays = true
validate() {
super.validate()
if (!this.roomTag) throw new Error("YourKind requires a room")
}
setPubkeys(pubkeys: string[]) {
return this.dropTags(spec(["p"])).addTags(...uniq(pubkeys).map(pk => ["p", pk]))
}
}
export const YourKind = new KindFactory({
kind: YOUR_KIND,
reader: YourKindReader,
writer: YourKindWriter,
})
```
3. Export it from `packages/domain/src/index.ts`.
4. Add `packages/domain/__tests__/YourKind.test.ts` — every kind has one. Use the `read`, `write`,
and `buildTemplate` helpers from `./helpers.js`, and cover: reading represented tags,
round-tripping without duplicating tags, and any `validate()` rule.
## Available kinds
Profile, Note, Comment, Thread, Delete, Reaction, Report, Poll, PollResponse, Classified,
TimeEvent, AppData, Handler, HandlerRecommendation, Feed, Pin, Pinboard.
Lists: FollowList, MuteList, PinList, BookmarkList, TopicList, EmojiList, RelayList, RelaySet,
SearchRelayList, MessagingRelayList, BlossomServerList, BlockedRelayList, CommunityList,
FeedList, RoomList.
Zaps: ZapRequest, ZapReceipt, ZapGoal.
NIP-29 rooms: RoomMeta, RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember,
RoomRemoveMember, RoomMembers, RoomAdmins, RoomCreatePermission, RoomPins, RoomUpdatePins.
Relay membership: RelayInvite, RelayJoin, RelayLeave, RelayAddMember, RelayRemoveMember,
RelayMembers, RelayRole.
## Related skills ## Related skills
- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you - `welshman-util` — the raw `TrustedEvent`/`EventTemplate` types, kind constants, tag getters, and the `RelaySelection` routing DSL + `Resolver`/`RelayScenario` these classes build on.
- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters - `welshman-app` — the instance-based app layer whose `Domain` plugin supplies the `KindContext`, whose `Router` plugin supplies the resolver, and whose data plugins use these readers as `eventToItem`.
- `welshman-store` — indexing decoded readers into reactive collections - `welshman-signer` — the `ISigner` interface and NIP-44 `decrypt`/`encrypt` used for private list tags and for signing rendered templates.

View file

@ -326,6 +326,7 @@ console.log('Authors in feed:', [...authors])
- **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories. - **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories.
- **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result. - **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result.
- **Windowing is calibrated with COUNT.** From the second page on, a windowed loader asks the relays how many events its next window holds (NIP-45, through `countPage`) and scales the window toward a full page, up to three probes. The first page keeps the window `guessFilterDelta` picked, so no events wait on a count, and relays that do not support COUNT get the blind widening instead.
- **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset. - **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset.
- **`canCompile` returns `false` only for `FeedType.Difference`** (and recursively for `Union`/`Intersection` whose sub-feeds include a `Difference`). DVM and List feeds return `true` from `canCompile` and are compiled asynchronously by `_compileDvms` and `_compileLists` inside the compiler's `compile` method. The feeds handled specially by `FeedController` (outside the compiled request flow) are `Difference`, `Union`, and `Intersection` — but only when `canCompile` returns `false` for them. - **`canCompile` returns `false` only for `FeedType.Difference`** (and recursively for `Union`/`Intersection` whose sub-feeds include a `Difference`). DVM and List feeds return `true` from `canCompile` and are compiled asynchronously by `_compileDvms` and `_compileLists` inside the compiler's `compile` method. The feeds handled specially by `FeedController` (outside the compiled request flow) are `Difference`, `Union`, and `Intersection` — but only when `canCompile` returns `false` for them.
- **`simplifyFeed`** flattens nested same-type set operations (e.g. `union(union(a,b), c)` → `union(a,b,c)`). Run it before storing or serializing feed definitions. - **`simplifyFeed`** flattens nested same-type set operations (e.g. `union(union(a,b), c)` → `union(a,b,c)`). Run it before storing or serializing feed definitions.

View file

@ -54,12 +54,13 @@ Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` | | `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
| `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` | | `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` |
| `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold | | `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold |
| `load(options)` | Pre-built loader with a 30 ms batch delay, 3 s timeout and 0.5 threshold. It auto-closes after EOSE, timeout, or disconnect, and resolves when half the relays' subscriptions have closed. It carries no context, so it only works where no pool or repository is needed. | | `loadComplete(options)` | Pre-built loader with a 30 ms batch delay and a 3 s timeout. It auto-closes after EOSE, timeout, or disconnect, and resolves once every relay's subscription has closed, so an empty result means every relay answered nothing. It carries no context, so it only works where no pool or repository is needed. |
| `loadLenient(options)` | The same loader with a 0.5 threshold, resolving once half the relays have closed. The rest stay open and their events still arrive, so use it where nothing routes on the returned array. |
`request` / `requestOne` options (`BaseRequestOptions`): `request` / `requestOne` options (`BaseRequestOptions`):
- `relay` / `relays` — relay url(s) - `relay` / `relays` — relay url(s)
- `filters` — array of nostr `Filter` objects - `filters` — array of nostr `Filter` objects
- `autoClose?: boolean` — close the subscription after EOSE or on socket disconnect - `autoClose?: boolean` — close the subscription after EOSE, on socket disconnect, or 30 seconds in, whichever comes first, so a relay that answers nothing can't hold the request open
- `signal?: AbortSignal` — cancellation - `signal?: AbortSignal` — cancellation
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`) - `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
- `context?: AdapterContext` - `context?: AdapterContext`
@ -73,6 +74,17 @@ Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader
Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids. Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids.
### Count
| Export | Description |
|--------|-------------|
| `countOne(options)` | Asks one relay how many events match some filters (NIP-45); returns `Promise<number \| undefined>` |
| `count(options)` | Asks several relays and takes the highest answer; returns `Promise<number \| undefined>` |
| `supportsCount(relay)` | Whether that relay has answered a COUNT this session — `true`, `false`, or `undefined` if it has not been asked |
| `COUNT_TIMEOUT` | 3000 ms, how long a relay has to answer before it counts as refusing |
Options are `{relay | relays, filters, context?, signal?}`. `undefined` means no relay supports COUNT, which is a different answer from `0`. NIP-45 is optional and a NIP-11 document is not always honest about it, so support is read off the wire: a relay that answers with CLOSED, or says nothing at all, is remembered and not asked again. Relays hold overlapping subsets of the network, so what `count` returns is a lower bound on the total rather than the total.
### Publish ### Publish
| Export | Description | | Export | Description |
@ -169,8 +181,8 @@ Emits `"update"` with a `RepositoryUpdate` on every change.
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types | | `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages | | `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, `isRelayCount()`, … | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages | | `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, `isClientCount()`, … | Type guards for client messages |
| `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) | | `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) |
### WrapManager ### WrapManager
@ -221,8 +233,10 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
import {makeLoader} from '@welshman/net' import {makeLoader} from '@welshman/net'
// Bind a loader to the context once; concurrent calls within `delay` collapse // Bind a loader to the context once; concurrent calls within `delay` collapse
// into a single REQ per relay. // into a single REQ per relay. Without a `threshold` it resolves when every relay has closed
const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context}) // or timed out, which is the exported `loadComplete`; `threshold: 0.5` resolves at half, which is
// `loadLenient`.
const load = makeLoader({delay: 30, timeout: 3000, context})
const events = await load({ const events = await load({
relays: ['wss://relay.example.com', 'wss://relay2.example.com'], relays: ['wss://relay.example.com', 'wss://relay2.example.com'],

View file

@ -237,6 +237,7 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first. - **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first.
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check. - **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check.
- **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away. - **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away.
- **A wrap that needs proof of work is mined by its wrapper.** `Nip59` knows nothing about NIP-13; pass your own wrapper with `withWrapper`, then `wrapper.sign(await minePow(wrap, difficulty))` on what comes back. Mining changes the id, so only the key that signed the wrap can sign it again — an ephemeral wrapper `Nip59` made for itself is gone by then.
- **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret. - **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret.
- **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value. - **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value.
- **Both `nip04` and `nip44` are supported** on all signers. Prefer `nip44` for new code; `nip04` is provided for backwards compatibility with older clients. - **Both `nip04` and `nip44` are supported** on all signers. Prefer `nip44` for new code; `nip04` is provided for backwards compatibility with older clients.

View file

@ -138,7 +138,7 @@ interface StorageProvider {
| Export | Description | | Export | Description |
|---|---| |---|---|
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) | | `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) |
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store (`WritableWithGetter` / `ReadableWithGetter`) | | `withGetter(store)` | Adds a `.get()` method to any store, returning `S & {get(): T}` so the store's own members survive in the type (e.g. `withGetter(synced(...)).ready`) |
### Derivation helpers ### Derivation helpers
@ -320,7 +320,7 @@ const loadBookmark = makeLoadItem<Bookmark>(
async (pubkey: string) => { async (pubkey: string) => {
const scenario = await app.use(Router).resolve([outbox(pubkey)]) const scenario = await app.use(Router).resolve([outbox(pubkey)])
await app.use(Network).load({ await app.use(Network).loadComplete({
relays: scenario.getUrls(), relays: scenario.getUrls(),
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }], filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
}) })

View file

@ -75,8 +75,10 @@ NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welsh
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` | | `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits, in a web worker; returns a `ProofOfWork` |
| `getPow(event)` | Leading zero bits on an event's id | | `minePow(event, difficulty, signal?)` | The same search without a worker, so it runs under node; returns `Promise<HashedEvent>` |
| `countLeadingZeroes(id)` | Leading zero bits on an id |
| `getPow(event)` | The difficulty an event's `nonce` tag commits to, or 0 when its id does not reach it |
| `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target | | `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target |
### Event Kinds (constants) ### Event Kinds (constants)
@ -542,7 +544,7 @@ sendManagementRequest(url: string, request: ManagementRequest, authEvent: Signed
// ManagementResponse = { result?: any; error?: string } // ManagementResponse = { result?: any; error?: string }
``` ```
Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSignEvent`, `makeSupportedMethods`, and the matching `makeList*` readers. Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`/`makeListMethodAssignees`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSupportedMethods`, and the matching `makeList*` readers.
`ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user. `ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user.

2
.gitattributes vendored
View file

@ -1 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
pnpm-lock.yaml -diff pnpm-lock.yaml -diff

View file

@ -4,6 +4,10 @@ on:
push: push:
branches: [master] branches: [master]
tags: ["*"] tags: ["*"]
# A release goes public whenever its last artifact lands, which can be from a local run hours
# after the tag, so its copy on GitHub is kept up to date by polling
schedule:
- cron: "0 */3 * * *"
workflow_dispatch: workflow_dispatch:
# Serialized rather than cancel-in-progress: aborting a push halfway leaves the # Serialized rather than cancel-in-progress: aborting a push halfway leaves the
@ -14,7 +18,7 @@ concurrency:
jobs: jobs:
push-to-github: push-to-github:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'coracle/flotilla' if: github.repository == 'coracle/flotilla' && github.event_name != 'schedule'
steps: steps:
- name: Push master and tags to GitHub - name: Push master and tags to GitHub
@ -32,3 +36,24 @@ jobs:
"https://x-access-token:$GH_MIRROR_TOKEN@github.com/coracle-social/flotilla.git" \ "https://x-access-token:$GH_MIRROR_TOKEN@github.com/coracle-social/flotilla.git" \
'refs/heads/master:refs/heads/master' \ 'refs/heads/master:refs/heads/master' \
'refs/tags/*:refs/tags/*' 'refs/tags/*:refs/tags/*'
# Obtainium reads releases from whichever url a user added, and many added the GitHub one
copy-release-to-github:
runs-on: ubuntu-latest
needs: push-to-github
if: ${{ !cancelled() && github.repository == 'coracle/flotilla' && needs.push-to-github.result != 'failure' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Copy gitea's latest release to GitHub
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_MIRROR_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
run: node scripts/release/github.mjs

View file

@ -1,9 +1,15 @@
name: Container Image Build and Publish name: Release
on: on:
push: push:
branches: [master] tags: ["*.*.*"]
# Reruns steps of the release for package.json's version, from the branch it's run on
workflow_dispatch: workflow_dispatch:
inputs:
steps:
description: Steps to run, such as "fdroid" (all when empty)
required: false
default: ""
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@ -14,30 +20,9 @@ env:
IMAGE_NAME: coracle/flotilla IMAGE_NAME: coracle/flotilla
jobs: jobs:
lint-check: image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push'
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: permissions:
contents: read contents: read
packages: write packages: write
@ -59,8 +44,8 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -68,7 +53,6 @@ jobs:
driver: docker-container driver: docker-container
- name: Build and push Docker image - name: Build and push Docker image
id: push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
@ -77,3 +61,31 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# The signed builds come from pnpm release:local, so nothing here holds a signing key
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies
run: |
corepack enable
pnpm i --frozen-lockfile
npm ci --prefix electron
- name: Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
STEPS: ${{ github.event.inputs.steps }}
run: pnpm release:ci --yes $STEPS

View file

@ -2,3 +2,6 @@
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Formatted by fdroidserver's rewritemeta, which fdroiddata checks
fdroid/metadata/

View file

@ -56,7 +56,7 @@ These are the things that most often get fixed by hand after the fact. Go throug
them before handing work back. The full style reference is under Development them before handing work back. The full style reference is under Development
Conventions below. Conventions below.
- **Comments** — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it. - **Comments** — a comment is one line, for a genuinely surprising thing: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. Where one line can't hold it, the argument belongs in the pull request rather than in the code. If a small refactor would make the comment stale, don't write it.
- **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable. - **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable.
- **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else. - **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else.
- **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary. - **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary.
@ -82,7 +82,7 @@ Conventions below.
A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in
callbacks and hot paths. callbacks and hot paths.
**Thunks:** **Publications** (`app.use(Publisher).publish`):
- Reduce UI latency by handling signatures and sending in background - Reduce UI latency by handling signatures and sending in background
- Return status that should be displayed to user - Return status that should be displayed to user
@ -93,7 +93,7 @@ callbacks and hot paths.
**Welshman Library Suite:** **Welshman Library Suite:**
- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …) - `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Publisher, Router, …)
- `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper` - `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull) - `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
- `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.) - `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.)
@ -188,7 +188,7 @@ callbacks and hot paths.
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters 1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
2. Wrap it: `const command = await app.use(Domain).command(writer)` 2. Wrap it: `const command = await app.use(Domain).command(writer)`
3. Publish it: `command.publish()` or `.publishToRelays(urls)` 3. Publish it: `command.publish()` or `.publishToRelays(urls)`
4. Display thunk status to user (for cancel/error handling) 4. Display publication status to user (for cancel/error handling)
Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already
return a `Command`, so `.then(publish)` is usually all you need. return a `Command`, so `.then(publish)` is usually all you need.
@ -235,7 +235,7 @@ See `.env.template` for all options.
**Capacitor Integration:** **Capacitor Integration:**
- Android: Full support, release builds via `pnpm release` (see README for the release flow) - Android: Full support, release builds via `pnpm release:local` (see README for the release flow)
- iOS: Full support (zaps disabled due to App Store policy) - iOS: Full support (zaps disabled due to App Store policy)
- PWA: Progressive Web App with service worker - PWA: Progressive Web App with service worker

View file

@ -1,5 +1,40 @@
# Changelog # Changelog
# 1.11.2
* Offer a way out when startup gets stuck waiting on a remote signer
* Sign out of a remote signer session whose app data was cleared, instead of stalling at startup
* Show a room's messages before its space finishes syncing
* Fix messages missing from feeds when some relays answer more slowly than others
* Add an F-Droid build, signed with the same key as the other APKs
* Keep GitHub releases up to date for Obtainium
# 1.11.1
* Open notes in flotilla
* Split the DM list into conversations and requests
* Redesign the goal list and detail pages
* List a space's admins and edit a member's permissions
* Let any member curate the space library, and add an option to let members invite
* Add native app shortcuts for common features
* Review what a health check will do before applying it
* Move the key backup reminder out of onboarding
* Simplify hosting rows on the home page
* Ask whether to transcribe a recording or send it as a voice note
* Refine thread detail view
* Scroll the space sidebar instead of using an overflow menu
* Re-enter a space on the page it was left on
* Improve permalinks and message sharing
* Start new users out with some dashboard activity
* Improve DM message style
* Fix long words and links overflowing chat bubbles
* Fix encrypted audio playback and read out loud
* Fix unread indicators, modal navigation and several scrolling issues
* Speed up feeds by not waiting on slow relays
* Retry auth more proactively to prevent hanging connections
* Add desktop build pipeline
* Add Obtainium and F-Droid publishing
# 1.11.0 # 1.11.0
* Replace the home page with a dashboard of unread conversations, space activity and a network feed * Replace the home page with a dashboard of unread conversations, space activity and a network feed

View file

@ -49,6 +49,7 @@ To contribute, do the following:
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts - If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label - Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links` - If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
- Line endings are pinned to LF by `.gitattributes`. A Windows clone made before that was added keeps CRLF and fails `pnpm run lint`. Commit or stash your work first, then run `git rm --cached -r . && git reset --hard` once to re-checkout every file.
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes. - Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR. - Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`. - Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.

View file

@ -4,11 +4,11 @@
# docker run -p 3000:3000 flotilla # docker run -p 3000:3000 flotilla
# #
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build. # 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 scripts/build/app.sh for branding config.
# #
# Self-hosted deployments can override branding/feature settings by passing # Self-hosted deployments can override branding/feature settings by passing
# VITE_* build args below (e.g. VITE_EMAIL_NOTIFICATION_SERVER). Unset args keep # VITE_* build args below (e.g. VITE_EMAIL_NOTIFICATION_SERVER). Unset args keep
# the value from the tracked .env (scripts/build.sh sources it and restores the # the value from the tracked .env (scripts/build/web.sh sources it and restores the
# exported environment), so local overrides never clobber upstream defaults. # exported environment), so local overrides never clobber upstream defaults.
# https://pnpm.io/docker#example-3-build-on-cicd # https://pnpm.io/docker#example-3-build-on-cicd
@ -20,6 +20,7 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
ENV NODE_OPTIONS=--max_old_space_size=16384 ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages ./packages
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
COPY . . COPY . .
@ -41,7 +42,8 @@ FROM node:24-slim AS production
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app/build /app/build COPY --from=builder /app/build /app/build
COPY --from=builder /app/build-server/server.js /app/server.js # The bundle splits, and the chunks it imports at runtime sit beside it.
COPY --from=builder /app/build-server/ /app/
EXPOSE 3000 EXPOSE 3000
USER node USER node
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -40,7 +40,7 @@ Create an `.env.local` file to override any of the values in `.env`:
- `VITE_PLATFORM_TERMS` - URL to your terms of service page - `VITE_PLATFORM_TERMS` - URL to your terms of service page
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page - `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
- `VITE_PLATFORM_LOGEE` - A hex pubkey which will receive logs users send from their privacy settings - `VITE_PLATFORM_LOGEE` - A hex pubkey which will receive logs users send from their privacy settings
- `VITE_THEME` - The visual preset components are styled with: `clay`, `flat`, or `navy` - `VITE_THEME` - The visual preset components are styled with: `clay`, `flat`, `navy`, or `nature`
**Platform mode** **Platform mode**
- `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page. - `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
@ -62,7 +62,7 @@ Create an `.env.local` file to override any of the values in `.env`:
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup) - `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 - `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 `scripts/build/app.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. 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.
@ -142,9 +142,10 @@ Wayland docks can identify it. It leaves existing user and system launchers alon
entry from a development run, and updates the entry when an update renames the AppImage. Windows entry from a development run, and updates the entry when an update renames the AppImage. Windows
uses the executable's icon resources. uses the executable's icon resources.
Linux packaging requires Linux. Windows packaging from Linux uses the pinned official Linux packaging from macOS and Windows packaging from Linux or macOS use the pinned official
`electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared `electronuserland/builder` Wine image through Docker, copying in only a temporary copy of the prepared
Electron project. Native addons need a target-OS ABI rebuild and cannot use this cross-build path. Electron project. Set `DOCKER=podman` in `.env.local` to use Podman instead. Native addons need a
target-OS ABI rebuild and cannot use this cross-build path.
Native Windows preparation needs Bash on PATH, for example Git Bash. DMG creation requires macOS. On Native Windows preparation needs Bash on PATH, for example Git Bash. DMG creation requires macOS. On
Linux, `pnpm run package:desktop:macos --dir` prepares unsigned bundles for inspection only, and Linux, `pnpm run package:desktop:macos --dir` prepares unsigned bundles for inspection only, and
verifies nothing about the macOS runtime, Gatekeeper, or signing. verifies nothing about the macOS runtime, Gatekeeper, or signing.
@ -181,59 +182,70 @@ both ZIPs with matching hashes. Do not hand-create or merge updater manifests.
## Releasing ## Releasing
`pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects, A release is two runs against one tag. `pnpm release:local` does everything that needs a signing
the signed APK on gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect, key, so those keys never leave your machine: the web bundle and native projects, the signed APK on
and the desktop packages. It checks the tag, the changelog section, every credential and every gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect, and the signed and
tool up front, and refuses to start if any is missing. It finishes with a list of what's left to do notarized macOS packages. Pushing the tag starts the release workflow in
by hand, such as rolling out on Play and submitting for review. `.gitea/workflows/release.yml`, which needs nothing but its own gitea token and the registry's: it
builds the container image as `latest` and the version, and runs `pnpm release:ci` to build the
F-Droid apk and package the Linux and Windows apps. `.agents/skills/flotilla-release` has the
checklist.
Both runs check the tag, the changelog section, every credential and every tool up front, and
refuse to start if any is missing. Each finishes with a list of what's left to do by hand, such as
rolling out on Play and submitting for review.
```sh ```sh
pnpm bump minor # or patch, major, or an explicit x.y.z pnpm bump minor # or patch, major, or an explicit x.y.z
# write the CHANGELOG.md section for the new version # write the CHANGELOG.md section for the new version
git commit -am "Bump version" pnpm release:changelog # copies it to fastlane/ for F-Droid, which reads it from the tag
git add -A && git commit -m "Bump version"
git tag 1.12.0 && git push origin dev 1.12.0 git tag 1.12.0 && git push origin dev 1.12.0
pnpm release pnpm release:local
``` ```
`pnpm release --check` runs those checks and reports the plan without building anything. Naming `pnpm release:local --check` runs those checks and reports the plan without building anything.
steps runs a subset, such as `pnpm release ios` or `pnpm release apk gitea`. A step that fails stops the Naming steps runs a subset, such as `pnpm release:local ios` or `pnpm release:local apk gitea`. A
run and prints the command to pick up from there. step that fails stops the run and prints the command to pick up from there. To rerun CI steps, run
the Release workflow by hand from a branch with the fix, naming the steps; it releases
`package.json`'s version without moving its tag, and skips the container image.
| step | what it does | | step | run by | what it does |
| --- | --- | | --- | --- | --- |
| `web` | `scripts/build.sh`: web bundle, `cap sync`, generated icons and splash screens | | `web` | local | `scripts/build/app.sh`: web bundle, `cap sync`, generated icons and splash screens |
| `apk` | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` | | `apk` | local | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` |
| `fdroid` | reruns F-Droid's own preparation and build against the tag in a throwaway worktree | | `play` | local | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft (or `PLAY_STATUS`) |
| `play` | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft | | `ios` | local | `cap build ios` to an archive and IPA, uploaded with `altool`, then attached to the App Store version with its release notes once processed |
| `ios` | `cap build ios` to an archive and IPA, uploaded with `altool` | | `fdroid` | ci | builds the tag with F-Droid's recipe in their buildserver image and uploads the unsigned apk to the `flotilla-fdroid` package |
| `desktop` | `package:desktop:*` for this OS: Linux and Windows from Linux, signed and notarized macOS from a Mac | | `fdroid-sign` | local | waits for that apk, signs it with the distribution key, and uploads it beside it for F-Droid to verify its own build against |
| `gitea` | creates a draft release from the changelog, attaches the APK, desktop packages and update manifests, and publishes it once every platform is there | | `desktop` | both | `package:desktop:*` for this OS: signed and notarized macOS from a Mac, Linux and Windows from Linux |
| `zapstore` | `zsp publish zapstore.yaml` | | `gitea` | both | creates a draft release from the changelog, attaches what this run built, and publishes it once every platform is there |
| `zapstore` | local | `zsp publish --skip-preview --quiet zapstore.yaml` |
Linux builds the Linux and Windows packages and a Mac builds the macOS ones, so a release takes a Gitea's latest release is the desktop update feed, so the release stays a draft, hidden from
run on each, both ending in `gitea`. Gitea's latest release is the desktop update feed, so the release stays a updaters and Obtainium, until it has the APK and all three `latest*.yml` manifests. Whichever run
draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml` attaches the last of them publishes it. Each manifest is uploaded after the files it lists. A
manifests. Each manifest is uploaded after the files it lists. A mobile-only release can't be mobile-only release can't be published, so package the desktop apps for every release.
published, so package the desktop apps for every release.
Release notes come from the `CHANGELOG.md` section matching `package.json`'s version, so every Release notes come from the `CHANGELOG.md` section matching `package.json`'s version, so every
store shows the same text. The APK and zapstore share one artifact, whose path lives in store shows the same text. The APK and zapstore share one artifact, whose path lives in
`zapstore.yaml`. `zapstore.yaml`.
F-Droid builds from the tag on its own servers, so the `fdroid` step uploads nothing. It runs F-Droid builds from the tag on its own servers and ships our apk instead of its own when the two
[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree and match, so it keeps our signature ([reproducible builds](fdroid/README.md)). The `fdroid` step makes
fails the release before anything is published if that build breaks. Preparation patches source that apk the way F-Droid will. It runs last in CI, so a broken F-Droid build holds back only
with exact-match replacements, so it breaks quietly when the files it rewrites change. The step is F-Droid and `fdroid-sign`, not the desktop packages or the release. Preparation patches source with
slow because it installs and builds from scratch. exact-match replacements, so it breaks quietly when the files it rewrites change. The step is slow
because it installs and builds from scratch.
### Credentials ### Credentials
These go in `.env.local`, which is gitignored. `pnpm release --check` lists whichever are missing These go in `.env.local`, which is gitignored. `pnpm release:local --check` lists whichever are missing
along with how to get them. along with how to get them.
| variable | what it is | | variable | what it is |
| --- | --- | | --- | --- |
| `GITEA_TOKEN` | gitea access token with `write:repository`, from Settings → Applications | | `GITEA_TOKEN` | gitea access token with `write:repository` and `write:package`, from Settings → Applications |
| `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates | | `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates |
| `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key | | `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key |
| `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access | | `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access |
@ -274,7 +286,9 @@ Or, if you prefer to use a container:
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
``` ```
Alternatively, you can copy the build files into a directory of your choice and serve it yourself: Either one serves the build through `server.js`, which fills in the link preview for each page from the space, person or note its url names.
Alternatively, you can copy the build files into a directory of your choice and serve it yourself, in which case every page gets the platform's own preview:
```sh ```sh
mkdir ./mount mkdir ./mount

View file

@ -12,8 +12,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 52 versionCode 55
versionName "1.11.0" versionName "1.11.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -39,9 +39,7 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
<meta-data <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<provider <provider

View file

@ -200,12 +200,10 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val body = "New activity"
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_chat) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("Flotilla") .setContentTitle("Flotilla")
.setContentText(body) .setContentText("New message")
.setAutoCancel(true) .setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)

View file

@ -1,36 +1,36 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/android/capacitor')
include ':aparajita-capacitor-secure-storage' include ':aparajita-capacitor-secure-storage'
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.4/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.2/node_modules/@capacitor/app/android')
include ':capacitor-clipboard' include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/clipboard/android') project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.4/node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.3_@capacitor+core@8.5.2/node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.4/node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.5_@capacitor+core@8.5.2/node_modules/@capacitor/keyboard/android')
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications' include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications/android') project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.1.2_@capacitor+core@8.5.2/node_modules/@capacitor/push-notifications/android')
include ':capacitor-share' include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/share/android') project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.2_@capacitor+core@8.5.2/node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support' include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.1_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-android-dark-mode-support/android') project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.1_@capacitor+core@8.5.2/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
include ':capawesome-capacitor-badge' include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-badge/android') project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.5.2/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_fd4b5c957724da9f45e5678eca5c7fd4/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_46ef56d5c94707ead7edfcb859900e71/node_modules/nostr-signer-capacitor-plugin/android')

188
android/gradlew.bat vendored
View file

@ -1,94 +1,94 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License. @rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS, @rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0 @rem SPDX-License-Identifier: Apache-2.0
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH= set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

View file

@ -156,7 +156,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr> <tr>
<td>Push notifications</td> <td>Push notifications</td>
<td><span class="status s-ok">Implemented</span></td> <td><span class="status s-ok">Implemented</span></td>
<td><code>app/push/</code>, <code>settings/alerts</code>; web, FCM/APNs and Android fallback. US-086, US-120</td> <td><code>app/push/</code>, <code>settings/alerts</code>; web, FCM/APNs and Android fallback. US-086, US-120, US-136</td>
</tr> </tr>
<tr> <tr>
<td>Threads</td> <td>Threads</td>
@ -461,7 +461,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr> <tr>
<td>Branded Android app</td> <td>Branded Android app</td>
<td><span class="status s-ok">Implemented</span></td> <td><span class="status s-ok">Implemented</span></td>
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release</code></td> <td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release:local</code></td>
</tr> </tr>
<tr> <tr>
<td>Branded iOS app</td> <td>Branded iOS app</td>
@ -471,7 +471,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr> <tr>
<td>Branded desktop app</td> <td>Branded desktop app</td>
<td><span class="status s-ok">Implemented</span></td> <td><span class="status s-ok">Implemented</span></td>
<td><code>electron/</code>, <code>scripts/package-desktop.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td> <td><code>electron/</code>, <code>scripts/desktop/package.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
</tr> </tr>
<tr> <tr>
<td>PWA / installable web app</td> <td>PWA / installable web app</td>

View file

@ -442,6 +442,16 @@ Acceptance:
message on screen and no "jump to newest" control, even when other events were message on screen and no "jump to newest" control, even when other events were
published after the one linked to. published after the one linked to.
### US-027b — Jump to the unread divider from a permalink
As someone opening a push notification, I want the "New Messages" control to
take me to where my unread messages start, so that I can read what I missed.
Acceptance:
- Landing on a permalink and then asking for new messages scrolls to the unread
divider, and does not return to the message the permalink landed on.
### US-028 — Share a message somewhere else ### US-028 — Share a message somewhere else
As alice, I want to forward a message into another room in the same space, so As alice, I want to forward a message into another room in the same space, so
@ -875,12 +885,15 @@ resources members have collected.
Acceptance: Acceptance:
- Typing a term filters the shelf list to shelves whose title, description, or - A shelf card carries its title, its description, how many links are on it and
topics match. who has contributed.
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message - Typing a term filters the shelves to those whose title, description or topics
instead. match, and lists the links that match from any shelf.
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone - Opening a shelf shows its links; one that already holds a link still offers
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf". "Add a link".
- Alice is offered "Create Shelf" and "Add a link" on someone else's shelf, and
not its "Edit shelf". Its "Delete shelf" opens a dialog saying only the author
or an admin can delete a shelf.
### US-054 — Curate the library ### US-054 — Curate the library
@ -889,13 +902,56 @@ good material first.
Acceptance: Acceptance:
- Creating a shelf with a title and description adds it to the list and - Creating a shelf with a title, description and topics opens it; editing it
navigates into it; editing and deleting it from its menu update the list. from its menu updates its page, and deleting it returns to the library.
- Adding a pin with an external url shows it as a content card, and adding one - Several links pasted at once each become a link of its own and are offered no
with a nostr note link renders that note as a rich embed. title or description between them: an external url renders as the link, a
nostr note link as the note it points at.
- "Add to Library" on a post (for example a poll) with a shelf selected adds it - "Add to Library" on a post (for example a poll) with a shelf selected adds it
as a pin visible in that shelf; with no shelves yet, the dialog prompts to as a link on that shelf; with no shelves yet, the dialog prompts to create
create one. one.
- Renaming a link from its menu republishes it under the new title.
- bob signs through a browser extension, so everything he publishes here is
signed by something outside the page.
### US-137 — Discuss a shelf and a link on it
As alice, I want to comment on a shelf and on one link on it, so that a question
about what was collected sits with what it is about.
Acceptance:
- Opening a link raises its own discussion, and a comment posted there appears
under it.
- A link's card counts the comments on it.
- The shelf has a discussion of its own, for anything about the collection
rather than about one link.
### US-138 — Manage a shelf as a list
As bob, I want to work through a shelf as a list, so that tidying up many links
costs one action rather than one each.
Acceptance:
- A shelf renders as cards or as a list, and the list gives every link a
checkbox with a select-all above them.
- Moving the selected links to another shelf files them there and takes them off
this one.
- Deleting the selected links for everyone asks for confirmation first.
### US-139 — See who built a shelf and where its links point
As alice, I want to see who contributed to a shelf and what each link points at,
so that a collection reads as somebody's work rather than a list of identifiers.
Acceptance:
- A shelf header counts its contributors, opens the roster of them, and each name
in it opens that person's profile.
- A link to a post reads as a short entity label in the list view, the same way
rendered content names one.
- Following that label lands on the post.
### US-055 — Create community content from a room ### US-055 — Create community content from a room
@ -1074,6 +1130,19 @@ Acceptance:
- Clicking that card opens the quoted post. - Clicking that card opens the quoted post.
- A quote whose event has not arrived shows a loading placeholder in its place. - A quote whose event has not arrived shows a loading placeholder in its place.
### US-140 — Open a quote before it has loaded
As alice, I want a quote to open in the app whether or not its event has
arrived, so that tapping one never hands me to another client.
Acceptance:
- A quote surrounded by prose renders as a truncated label, and clicking one
whose event has not arrived opens a modal saying it is loading, with no new
tab opened.
- A quote whose event has to be fetched opens that event once it arrives.
- Clicking the label of a quoted thread opens that thread's page.
### US-066 — See distinctive inline tokens ### US-066 — See distinctive inline tokens
As alice, I want hashtags, mentions, emoji, code, and addresses to look As alice, I want hashtags, mentions, emoji, code, and addresses to look
@ -1146,6 +1215,8 @@ Acceptance:
- For a chat message, a "Sending..." toast appears and becomes "Message sent!" - For a chat message, a "Sending..." toast appears and becomes "Message sent!"
once the relay confirms; a failed retry leaves no success message and can be once the relay confirms; a failed retry leaves no success message and can be
retried again. retried again.
- A successful retry clears the "Failed to send!" indicator on the message
itself, in a room and in a conversation.
- For an article or comment, a successful retry clears the "Failed to send!" - For an article or comment, a successful retry clears the "Failed to send!"
indicator and restores the normal reaction and menu actions, with no toast. indicator and restores the normal reaction and menu actions, with no toast.
@ -1165,6 +1236,8 @@ Acceptance:
thread it was posted into. thread it was posted into.
- A failed post shows "Failed to send!" in the same row, opening the same - A failed post shows "Failed to send!" in the same row, opening the same
per-relay detail popover. per-relay detail popover.
- The comment composer closes as soon as the comment appears, rather than
waiting for the relay to confirm it.
### US-072 — A deleted post is marked deleted ### US-072 — A deleted post is marked deleted
@ -1279,6 +1352,18 @@ Acceptance:
- A note alice publishes from her own session appears at the top of bob's open - A note alice publishes from her own session appears at the top of bob's open
view without a reload. view without a reload.
### US-134 — Write a public post
As alice, I want to write a post from my own profile or from my network feed, so
that the people who follow me and the spaces I favorited both see it.
Acceptance:
- "Create post" on her profile and at the top of home's Network section both
open the same composer.
- Submitting it publishes the post to her own relays and to every space she
favorited, and it appears at the top of her profile's notes.
### US-080 — Preview a profile from anywhere ### US-080 — Preview a profile from anywhere
As alice, I want to click a name or avatar wherever it appears, so that I can As alice, I want to click a name or avatar wherever it appears, so that I can
@ -1344,6 +1429,10 @@ Acceptance:
permission is refused, an error toast reports it and the push toggle settles permission is refused, an error toast reports it and the push toggle settles
off, leaving badge and sound settings untouched. off, leaving badge and sound settings untouched.
- "Discard Changes" reverts unsaved edits to the last saved values. - "Discard Changes" reverts unsaved edits to the last saved values.
- Events alice is going to are set to remind her once, five minutes ahead. Lead
times are measured in minutes, and she can add and remove them.
- Saving a reminder less than a minute before the event shows an error and does
not save.
### US-087 — Configure content display ### US-087 — Configure content display
@ -1404,8 +1493,8 @@ Acceptance:
- Selecting "Dark" applies the dark theme to the document immediately, and - Selecting "Dark" applies the dark theme to the document immediately, and
"System" follows the OS color-scheme preference. "System" follows the OS color-scheme preference.
- Choosing a different app theme applies it and persists after a reload. - Choosing a different app theme applies it and persists after a reload.
- Dragging the font-size slider updates the displayed percentage and saves - Dragging the font-size slider updates the displayed percentage, and saving the
without a separate save button. page applies it and persists it across a reload.
### US-091 — Set up how people zap you ### US-091 — Set up how people zap you
@ -1451,6 +1540,8 @@ Acceptance:
- Searching the directory for the role's name filters to members holding it. - Searching the directory for the role's name filters to members holding it.
- Deleting a role warns that members keep their space membership, and confirming - Deleting a role warns that members keep their space membership, and confirming
removes it from the list; bob sees no "Manage Roles" option. removes it from the list; bob sees no "Manage Roles" option.
- A member's "Edit roles" in a space with no roles opens the roles list rather
than ending there.
### US-094 — Invite people to a space ### US-094 — Invite people to a space
@ -1461,8 +1552,8 @@ Acceptance:
- "Create Invite" shows a QR code and a copyable link, and copying puts it on - "Create Invite" shows a QR code and a copyable link, and copying puts it on
the clipboard. the clipboard.
- Where the relay supports it, the invite dialog's member search adds someone - Where the relay supports it, "Add members" in the invite dialog opens a member
straight into the member list. search that adds someone straight into the member list.
- A room-scoped invite link carries both the space and that room's join code. - A room-scoped invite link carries both the space and that room's join code.
### US-095 — Remove, ban, and restore members ### US-095 — Remove, ban, and restore members
@ -1538,6 +1629,24 @@ Acceptance:
- A member given "List banned members" finds "Banned Members" in the directory - A member given "List banned members" finds "Banned Members" in the directory
menu they had no menu in before, and still no "Admins". menu they had no menu in before, and still no "Admins".
### US-131 — Say where you are and find members on a map
As a member, I want to give a city, state or country on my profile and see where
the rest of a space is, so that I can find the people near me.
Acceptance:
- The location field on the profile form searches places by name, offers cities,
states and countries, and publishes what was picked to kind 0.
- Choosing a city publishes a geohash with it; choosing a state or a country
publishes the name alone, so nothing finer than the answer given goes out.
- The directory offers a map, pins every member who has a geohash with their
avatar, and opens a pin's profile when it is clicked. A map with nobody on it
says so.
- The directory's search narrows the map as it narrows the list.
- A member's card in the directory shows their location, so a state or a country
is visible where the map cannot pin it.
### US-098 — Browse and create hosted spaces ### US-098 — Browse and create hosted spaces
As a space owner, I want to see the spaces I host and spin up new ones, so that As a space owner, I want to see the spaces I host and spin up new ones, so that
@ -1667,6 +1776,30 @@ Acceptance:
- Its body is the words bob wrote rather than the quote his reply is prepended - Its body is the words bob wrote rather than the quote his reply is prepended
with, and a url in it is named by its host instead of spelled out. with, and a url in it is named by its host instead of spelled out.
### US-136 — Be notified about messages and nothing else
As alice, I want notifications to be about what people say to me, so that a
space with busy boards does not buzz my phone all day.
Acceptance:
- With push notifications on and the tab in the background, a message from bob
in a room alice is in raises one.
- A thread bob starts in the same room raises none, however much of it reaches
her client.
### US-137 — Reach a thread from the inbox
As alice, I want an unread reply to a thread to lead me to the thread, so that
the inbox never drops me in a room chat to go looking for it.
Acceptance:
- A comment bob writes on a thread in a room alice belongs to leaves the room's
inbox row showing the room's own last message.
- The same comment counts toward the space's unread thread activity, and the
thread it answers carries the unread dot on the threads board.
### US-105 — Land on the home page ### US-105 — Land on the home page
As a new user, I want the home page to route me somewhere useful, so that I'm As a new user, I want the home page to route me somewhere useful, so that I'm
@ -1713,11 +1846,21 @@ Acceptance:
note itself came from. note itself came from.
- It is a list of notes: a reply is counted on the note it answers rather than - It is a list of notes: a reply is counted on the note it answers rather than
drawn underneath it, and never appears as an item of its own. drawn underneath it, and never appears as an item of its own.
- Every note carries its reply count, including the ones with no replies.
- Scrolling to the end of the feed loads more rather than asking her to.
- The section fills from the relays that answer. One that takes the connection - The section fills from the relays that answer. One that takes the connection
and then says nothing does not hold it empty. and then says nothing does not hold it empty.
### US-133 — Keep the search relays that work
As alice, I want the search health check to leave alone the relays that do
support search, so that fixing it does not cost me the ones I chose.
Acceptance:
- The check reads each search relay's own metadata, so a relay that advertises
search is never offered for removal, and neither is one whose metadata never
arrives.
- A relay that answers and advertises no search is.
### US-106 — Share text into the app ### US-106 — Share text into the app
As alice, I want to hand text to Flotilla and choose where it lands, so that I As alice, I want to hand text to Flotilla and choose where it lands, so that I
@ -1775,28 +1918,6 @@ Acceptance:
- The nav item stays clear once he has opened the list, whether or not a thread - The nav item stays clear once he has opened the list, whether or not a thread
under it is still unread. under it is still unread.
### US-113 — See which threads are unread on a phone
As bob on a phone, I want the same dot on the thread that raised it, so that the
Threads indicator is as actionable on a phone as it is on a desktop.
Acceptance:
- The thread list in a board too narrow for the table is a list of links rather
than a table, and alice's thread carries a dot there; bob's own does not.
### US-114 — See which listings are unread
As bob, I want the same dot on a board whose items are cards rather than rows,
so that every content section answers "which one is new" the same way.
Acceptance:
- A listing alice posted raises an unread dot on the Classifieds nav item; one
bob posted himself does not.
- Opening the list keeps the dot on the corner of alice's card, and bob's own
card carries none.
### US-123 — Find a section whose newest item is old ### US-123 — Find a section whose newest item is old
As bob, I want a space to offer every kind of content it holds, so that a quiet As bob, I want a space to offer every kind of content it holds, so that a quiet
@ -1819,6 +1940,16 @@ Acceptance:
- Opening the list says there are no polls, and afterwards the space stops - Opening the list says there are no polls, and afterwards the space stops
offering the section. offering the section.
### US-132 — See a room whose newest message is old
As bob, I want a room nobody has spoken in for weeks to still tell me it has
something I have not read, so that a quiet room is not a room I forget.
Acceptance:
- A message two months old in a room bob has never opened raises a dot on the
space, and the room carries the same dot once the space is open.
## Out of scope ## Out of scope
Features the e2e suite cannot exercise, and what stops it. Features the e2e suite cannot exercise, and what stops it.
@ -1866,9 +1997,9 @@ hosted checkout and is confirmed by a backend webhook. The Lightning path for
the same invoice is covered by US-102. the same invoice is covered by US-102.
**External link-outs.** Self-hosted and third-party options on the **External link-outs.** Self-hosted and third-party options on the
space-creation page, the About page's source, blog, podcast and support links, space-creation page, and the About page's source, blog, podcast and support
and a note's timestamp permalink to another nostr client. The harness blocks links. The harness blocks navigation off the app's origin, so nothing about the
navigation off the app's origin, so nothing about the destination is observable. destination is observable.
**Diagnostic log sending.** The privacy page's button that bundles client-side **Diagnostic log sending.** The privacy page's button that bundles client-side
logs into a DM to the platform's support contact. It targets a hardcoded pubkey logs into a DM to the platform's support contact. It targets a hardcoded pubkey
@ -1879,7 +2010,7 @@ does not serve is answered by an empty relay rather than refused, so no spec can
that fails. That leaves one invariant untested: a send whose reads fail before the message exists that fails. That leaves one invariant untested: a send whose reads fail before the message exists
must keep the text in the composer and say so, rather than clearing as though it went. It is the must keep the text in the composer and say so, rather than clearing as though it went. It is the
shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving
empty, so a failure there aborts a publish before its thunk is made and nothing reaches the empty, so a failure there aborts a publish before its publication is made and nothing reaches the
timeline to carry a status. Testing it needs a seam for making a relay unusable. timeline to carry a status. Testing it needs a seam for making a relay unusable.
**Internals with no user-visible surface.** The legacy session-storage format **Internals with no user-visible surface.** The legacy session-storage format

View file

@ -2,35 +2,45 @@ import type {BrowserContext} from "@playwright/test"
import {ms} from "@welshman/lib" import {ms} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys" import type {TestUser} from "../keys"
import {injectEvents, injectSession} from "./session" import {injectChecked, injectEvents, injectSession} from "./session"
// Must match TEST_ENV_KEY in src/lib/test/env.ts. // Must match TEST_ENV_KEY in src/lib/test/env.ts.
const TEST_ENV_KEY = "__TEST_ENV__" const TEST_ENV_KEY = "__TEST_ENV__"
// Set the first time the app reads a value out of the injected env, the only evidence this side // Set the first time the app reads the injected env, the only evidence here that src/app/env.ts ran.
// has that the hook in src/app/env.ts ran.
const TEST_ENV_READ_KEY = "__TEST_ENV_READ__" const TEST_ENV_READ_KEY = "__TEST_ENV_READ__"
export type BootOptions = { export type BootOptions = {
// Every relay list the app reads at startup is pointed here, so it can only dial relays the // Every relay list the app reads at startup is pointed here, so it can only dial the scenario's.
// scenario created.
relays: string[] relays: string[]
// What a pubkey's own lists are resolved from, which is a relay of its own only when the // What a pubkey's own lists are resolved from, defaulting to `relays`.
// scenario opened one. Defaults to `relays`.
indexers?: string[] indexers?: string[]
spaces?: string[] spaces?: string[]
user?: TestUser user?: TestUser
// Whether this user signs through the window.nostr injectNip07 installed, rather than with a key.
nip07?: boolean
// What this user's client already has in local storage, e.g. their room list. // What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[] events?: TrustedEvent[]
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
checked?: Record<string, number>
path?: string path?: string
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything // VITE_ values the scenario sets for itself. Anything named here has to be something the test owns.
// named here has to be something the test owns, or the test fails on a leak.
env?: Record<string, string> env?: Record<string, string>
} }
export const boot = async ( export const boot = async (
context: BrowserContext, context: BrowserContext,
{relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions, {
relays,
indexers = relays,
spaces = [],
user,
nip07 = false,
events = [],
checked,
path = "/",
env = {},
}: BootOptions,
) => { ) => {
const urls = relays.join(",") const urls = relays.join(",")
@ -58,8 +68,7 @@ export const boot = async (
VITE_DEFAULT_SPACES: spaces.join(","), VITE_DEFAULT_SPACES: spaces.join(","),
VITE_PLATFORM_RELAYS: "", VITE_PLATFORM_RELAYS: "",
VITE_BLOCKED_RELAYS: "", VITE_BLOCKED_RELAYS: "",
// Nothing serves this url, so a push bridge connection is reported as a leak instead of // Nothing serves this url, so a push bridge connection is reported as a leak.
// blending into the traffic of a relay the scenario did create.
VITE_PUSH_BRIDGE: "ws://localhost:1/", VITE_PUSH_BRIDGE: "ws://localhost:1/",
...env, ...env,
}, },
@ -67,18 +76,19 @@ export const boot = async (
) )
if (user) { if (user) {
await injectSession(context, user) await injectSession(context, user, nip07)
await injectEvents(context, events) await injectEvents(context, events)
} }
if (checked) {
await injectChecked(context, checked)
}
const page = await context.newPage() const page = await context.newPage()
await page.goto(path) await page.goto(path)
// The root layout renders nothing until its async setup block resolves, so the shell appearing // The root layout renders nothing until its async setup resolves, and a rejected session renders the landing dialog.
// is the first point at which the app is running. With a session injected, wait for the signed-in
// nav instead. A session the app rejected renders the landing dialog, and failing on that here
// reads far better than the assertions it would break later.
const shell = page.locator(user ? ".primary-nav" : ".fl") const shell = page.locator(user ? ".primary-nav" : ".fl")
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
@ -94,8 +104,7 @@ export const boot = async (
} }
} }
// src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them // src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them.
// against either the env above or .env's real relays.
const usedTestEnv = await page.evaluate( const usedTestEnv = await page.evaluate(
key => Boolean(Reflect.get(window, key)), key => Boolean(Reflect.get(window, key)),
TEST_ENV_READ_KEY, TEST_ENV_READ_KEY,

View file

@ -1,15 +1,10 @@
import type {Page} from "@playwright/test" import type {Page} from "@playwright/test"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to // Must match the database name and the `events` table in src/app/storage.ts.
// one identity.
const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}` const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}`
/** /** What this user's client has written to disk. Events reach indexeddb in three-second batches. */
* What this user's client has written to disk so far. Events reach indexeddb in three-second
* batches with nothing in the ui to say when one has landed, so a spec about what survives a
* restart waits on this before it reloads.
*/
export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> => export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> =>
page.evaluate(async name => { page.evaluate(async name => {
const open = await new Promise<IDBDatabase>((resolve, reject) => { const open = await new Promise<IDBDatabase>((resolve, reject) => {
@ -19,8 +14,7 @@ export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEve
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
// An unversioned open creates the database when it is missing, so a client that has not written // An unversioned open creates the database when it is missing, so a client with no writes has no store.
// anything yet has no store to read.
if (!open.objectStoreNames.contains("events")) { if (!open.objectStoreNames.contains("events")) {
open.close() open.close()

View file

@ -2,8 +2,7 @@ import type {BrowserContext} from "@playwright/test"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import type {TestUser} from "../keys" import type {TestUser} from "../keys"
// The function playwright installs on window for the shim below to call into. The keys live in // The keys live in node, so a signer built in the page would not be the one seeding signs with.
// node, so a signer built in the page would not be the one seeding signs with.
const TEST_NIP07_KEY = "__TEST_NIP07__" const TEST_NIP07_KEY = "__TEST_NIP07__"
type Nip07Call = type Nip07Call =
@ -11,13 +10,7 @@ type Nip07Call =
| {method: "signEvent"; template: StampedEvent} | {method: "signEvent"; template: StampedEvent}
| {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string} | {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string}
/** /** A NIP-07 provider backed by a test identity's real signer, installed before the page navigates. */
* 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) => { export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => { await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {
if (call.method === "getPublicKey") { if (call.method === "getPublicKey") {
@ -36,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
}) })
await context.addInitScript(key => { await context.addInitScript(key => {
const call = (request: Nip07Call) => Reflect.get(window, key)(request) // An extension answers over postMessage, which structured-clones every argument it is given.
const call = (request: Nip07Call) => Reflect.get(window, key)(structuredClone(request))
Object.assign(window, { Object.assign(window, {
nostr: { nostr: {

View file

@ -7,19 +7,20 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
const TEST_EVENTS_KEY = "__TEST_EVENTS__" const TEST_EVENTS_KEY = "__TEST_EVENTS__"
// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so // The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
// restoreSession can build a signer from it without the storage encoding a real login goes through. // A nip07 session carries no key and signs through whatever injectNip07 put on window.
// addInitScript runs before any page script, so this has to be called before navigating. export const injectSession = (context: BrowserContext, user: TestUser, nip07 = false) =>
export const injectSession = (context: BrowserContext, user: TestUser) =>
context.addInitScript( context.addInitScript(
([key, session]) => { ([key, session]) => {
Object.assign(window, {[key]: session}) Object.assign(window, {[key]: session})
}, },
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, [
TEST_SESSION_KEY,
nip07 ? {method: "nip07", data: {}} : {method: "nip01", data: {secret: user.secret}},
] as const,
) )
// The repository contents the app loads once the injected session is restored, the local cache a // The local cache a returning user would boot with, loaded once the injected session is restored.
// returning user would boot with.
export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
context.addInitScript( context.addInitScript(
([key, value]) => { ([key, value]) => {
@ -27,3 +28,15 @@ export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
}, },
[TEST_EVENTS_KEY, events] as const, [TEST_EVENTS_KEY, events] as const,
) )
// Read state as an earlier visit left it, so a room can open with unread messages already in it.
export const injectChecked = (context: BrowserContext, checked: Record<string, number>) =>
context.addInitScript(
([key, value]) => {
// An init script also runs on about:blank, where reading localStorage is a SecurityError.
if (location.protocol.startsWith("http")) {
localStorage.setItem(key, value)
}
},
["CapacitorStorage.checked", JSON.stringify(checked)] as const,
)

View file

@ -1,22 +1,13 @@
import type {BrowserContext} from "@playwright/test" import type {BrowserContext} from "@playwright/test"
// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the // `supports` is what WalletConnect.svelte gates the connection on, and the alias names it on screen.
// connection on, and the node's alias is what the wallet page names the connection by.
export type WebLnInfo = { export type WebLnInfo = {
supports?: string[] supports?: string[]
node?: {alias?: string; pubkey?: string} node?: {alias?: string; pubkey?: string}
version?: string version?: string
} }
/** /** A WebLN provider on `window`, installed before the page navigates: WalletConnect reads it while it renders. */
* A WebLN provider on `window`, in the shape a browser extension installs. Connecting is a
* capability handshake and nothing more, so the whole provider answers from a literal in the page —
* paying an invoice and issuing one are past the boundary this harness stops at, and calling either
* here throws rather than pretending.
*
* Install it before the page navigates. WalletConnect reads `window.webln` while it renders, to
* decide whether to offer the button at all.
*/
export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) => export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) =>
context.addInitScript( context.addInitScript(
$info => { $info => {

View file

@ -1,20 +1,13 @@
import {inspect} from "node:util" import {inspect} from "node:util"
import type {BrowserContext, ConsoleMessage} from "@playwright/test" import type {BrowserContext, ConsoleMessage} from "@playwright/test"
// A CSP refusal reaches the console and nothing else, so a policy that has rotted past the script // A CSP refusal reaches the console and nothing else, so a policy that has rotted is invisible (#535).
// it names is invisible to every spec: app.html's requestIdleCallback shim was refused on every
// platform for a week with the suite green (#535).
const isRefusal = (text: string) => text.includes("Content Security Policy") const isRefusal = (text: string) => text.includes("Content Security Policy")
// Every test recreates the zooid container, chromium aborts what the page had in flight when the // Recreating the container between tests aborts route chunks in flight, until #529 stops the churn.
// interfaces churn, and sveltekit reports a route chunk lost that way as an uncaught TypeError. It
// reaches nearly every spec — 256 of the 258 faults a full survey run raised — so it stays in the
// log and out of the fault set until #529 stops the churn.
const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module") const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module")
// Chrome reports a failed request as "Failed to load resource: the server responded with a status // Chrome carries a failed request's url nowhere but the message's location.
// of 404 (Not Found)" and carries the url nowhere but the message's location, so a line built from
// the text alone cannot say which resource went missing.
const locate = (message: ConsoleMessage) => { const locate = (message: ConsoleMessage) => {
const text = message.text() const text = message.text()
const {url} = message.location() const {url} = message.location()
@ -22,9 +15,7 @@ const locate = (message: ConsoleMessage) => {
return url && !text.includes(url) ? `${text} ${url}` : text return url && !text.includes(url) ? `${text} ${url}` : text
} }
// Playwright builds this from the page's exception details, and a page that throws something other // A page that throws something other than an Error leaves playwright neither message nor stack.
// than an Error leaves it with neither message nor stack — which is how a fault used to reach the
// console log as a bare "uncaught:".
const describe = (error: Error) => error.stack || error.message || inspect(error) const describe = (error: Error) => error.stack || error.message || inspect(error)
export type FaultWatch = { export type FaultWatch = {
@ -33,9 +24,7 @@ export type FaultWatch = {
// The subset of it that means the app broke rather than the box being noisy. // The subset of it that means the app broke rather than the box being noisy.
found: string[] found: string[]
observe(context: BrowserContext, who: string): void observe(context: BrowserContext, who: string): void
// Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the // Throws on an uncaught exception or code the browser refused to run, not on a failed request.
// browser refused to run. A failed request or a noisy warning is neither — a dev server is full
// of both — so those stay in the log.
assertNone(): void assertNone(): void
} }

View file

@ -7,10 +7,7 @@ export type TestFile = {
buffer: Buffer buffer: Buffer
} }
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched // Gif rather than png, since compressFileForUpload passes it through instead of re-encoding it.
// instead of re-encoding it 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. The base64 is what a spec hands
// to the page, since a Buffer does not survive the trip into evaluate().
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
export const GIF = Buffer.from(GIF_BASE64, "base64") export const GIF = Buffer.from(GIF_BASE64, "base64")
@ -20,8 +17,7 @@ export const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=
export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF}) export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF})
// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand // Every picker opens the browser's own chooser, and the input behind it is never on screen.
// it a file: the input behind it is never on screen.
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => { export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
const chooser = page.waitForEvent("filechooser") const chooser = page.waitForEvent("filechooser")

View file

@ -21,6 +21,7 @@ import {
formatTranscript, formatTranscript,
installWebSocketRoutes, installWebSocketRoutes,
silenceRelay, silenceRelay,
withholdEose,
} from "./net/websocket" } from "./net/websocket"
import {watchFaults} from "./faults" import {watchFaults} from "./faults"
import {boot} from "./app/boot" import {boot} from "./app/boot"
@ -47,6 +48,7 @@ export {
getPublishedEvents, getPublishedEvents,
getTranscript, getTranscript,
silenceRelay, silenceRelay,
withholdEose,
} from "./net/websocket" } from "./net/websocket"
export {readCachedEvents} from "./app/cache" export {readCachedEvents} from "./app/cache"
export { export {
@ -86,12 +88,14 @@ export {
getHosting, getHosting,
mockBlossom, mockBlossom,
mockDufflepud, mockDufflepud,
mockGeocoding,
mockLivekit, mockLivekit,
mockOpenRouterSpeech, mockOpenRouterSpeech,
mockOpenRouterTranscription, mockOpenRouterTranscription,
} from "./net/http" } from "./net/http"
export type { export type {
DufflepudFixtures, DufflepudFixtures,
GeocodedPlace,
HostingFixtures, HostingFixtures,
HostingHandle, HostingHandle,
HostingRecord, HostingRecord,
@ -99,8 +103,7 @@ export type {
} from "./net/http" } from "./net/http"
export type {WebLnInfo} from "./app/webln" export type {WebLnInfo} from "./app/webln"
// Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with // Mirrors encodeRelay in src/app/relays.ts, which cannot be imported without pulling in sveltekit.
// it sveltekit.
const encodeRelay = (url: string) => const encodeRelay = (url: string) =>
encodeURIComponent( encodeURIComponent(
normalizeRelayUrl(url) normalizeRelayUrl(url)
@ -112,51 +115,44 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}`
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined // Mirrors makeChatId in src/app/chats.ts.
// with commas — see makeChatId in src/app/chats.ts.
export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
export const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}` export const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}`
// A literal as a pattern, for a url that carries a query string or a modal's hash alongside the // Matches a path literally, for one whose query string, hash or dots would otherwise be wildcards.
// path being matched, or a host whose dots would otherwise be wildcards.
export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// What a page is opened with, over and above the scenario's own relays. // What a page is opened with, over and above the scenario's own relays.
export type PageOptions = { export type PageOptions = {
// Overrides the project's context options, for a spec that needs a viewport or a permission of // Overrides the project's context options, for a spec needing its own viewport or permission.
// its own.
context?: BrowserContextOptions context?: BrowserContextOptions
// VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space or // VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
// the domain hosted relays are created under. See BootOptions in app/boot.ts.
env?: Record<string, string> env?: Record<string, string>
// A NIP-07 provider signing as this user, for a login that goes through an extension. // A NIP-07 provider signing as this user, so that `as` signs the way an extension does.
nip07?: TestUser nip07?: TestUser
// A WebLN provider on window, for a wallet that gets connected through an extension. // A WebLN provider on window, for a wallet that gets connected through an extension.
webln?: WebLnInfo webln?: WebLnInfo
// A blossom server, installed before the page boots. mockBlossom called on the page `as()` // A blossom server installed before boot, for a url src/app/sync.ts probes and caches on load.
// returns arrives after src/app/sync.ts has probed and cached a space's own url, so a spec whose
// server is one the app probes on load has to name it here instead.
blossom?: BlossomOptions blossom?: BlossomOptions
// Fields merged over a relay's own nip-11 document, keyed by relay url. // Fields merged over a relay's own nip-11 document, keyed by relay url.
relayInfo?: RelayInfoOverrides relayInfo?: RelayInfoOverrides
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for // What the hosting backend already knows about this user; getHosting changes it mid-test.
// the handle that changes it mid-test.
hosting?: HostingFixtures hosting?: HostingFixtures
// Relay urls that take the socket and answer nothing, from the page's first connection onward. // Which paths this user has already read, for a spec that needs unread messages at first paint.
// `silenceRelay(page.context(), url)` is the same fault applied mid-test, which takes effect on checked?: Record<string, number>
// the next connection rather than this one. // Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test.
silent?: string[] silent?: string[]
// Relay urls that serve events and never eose, so every request runs out its own deadline.
eoseless?: string[]
} }
export type Harness = { export type Harness = {
zooid: Zooid zooid: Zooid
seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario> seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario>
// A logged-in page for a user, in its own browser context, with its own storage and its own // A logged-in page for a user, in its own browser context.
// sockets into the relays every other user is talking to.
as(user: TestUser, path?: string, options?: PageOptions): Promise<Page> as(user: TestUser, path?: string, options?: PageOptions): Promise<Page>
// The same page with no session injected, which is the only way to watch a login or a logout // The same page with no session injected, which is the only way to watch a login or a logout.
// happen.
visit(path?: string, options?: PageOptions): Promise<Page> visit(path?: string, options?: PageOptions): Promise<Page>
} }
@ -172,8 +168,7 @@ export type HarnessWorkerFixtures = {
} }
export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
// Playwright's own context, and the `page` fixture built on it, is unrouted, so a page born // Playwright's own context is unrouted, so a page born there boots against the relays in .env.
// there boots the app against the relays baked into .env.
context: async () => { context: async () => {
throw new Error( throw new Error(
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " + "The built-in `context` and `page` fixtures reach the real network. Open a page with the " +
@ -181,19 +176,16 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
"they navigate.", "they navigate.",
) )
}, },
// Playwright builds this one with `playwright.request.newContext()`, an http client in this // Playwright builds this with request.newContext(), which belongs to no browser context.
// process that belongs to no browser context, so `installHttpRoutes` cannot see it.
request: async () => { request: async () => {
throw new Error( throw new Error(
"The built-in `request` fixture makes http requests from node, where nothing intercepts " + "The built-in `request` fixture makes http requests from node, where nothing intercepts " +
"them. Anything the app fetches belongs in a mock installed by `as(user, path)`.", "them. Anything the app fetches belongs in a mock installed by `as(user, path)`.",
) )
}, },
// One container per worker, torn down when the worker ends. It is recreated between tests, in // One container per worker, recreated in the teardown of the test that finishes.
// the teardown of the test that finishes rather than the setup of the one that starts.
zooid: [ zooid: [
// playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern // playwright reads a fixture's dependencies off this pattern, so it stays a pattern.
// even when there are none.
// eslint-disable-next-line no-empty-pattern // eslint-disable-next-line no-empty-pattern
async ({}, use) => { async ({}, use) => {
const zooid = new Zooid() const zooid = new Zooid()
@ -228,12 +220,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
const {urls, indexerUrls, cache} = requireScenario() const {urls, indexerUrls, cache} = requireScenario()
await zooid.settle() await zooid.settle()
// The project's own `use` first, so a viewport or device descriptor set in // context.route does not see a request a service worker makes, and sveltekit registers one in dev.
// playwright.config.ts reaches the context rather than being dropped.
//
// context.route does not see a request a service worker makes, and sveltekit registers
// src/service-worker.js on every navigation in dev, so registration is blocked rather than
// left as the thing containment rests on.
const context = await browser.newContext({ const context = await browser.newContext({
...testInfo.project.use, ...testInfo.project.use,
serviceWorkers: "block", serviceWorkers: "block",
@ -242,14 +229,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
contexts.push({name: user?.name ?? "anonymous", context}) contexts.push({name: user?.name ?? "anonymous", context})
// `use.trace` and the built-in reporting only cover contexts playwright made itself, so a // use.trace and the built-in reporting only cover contexts playwright made itself.
// failure in a context opened here arrives with the app's own account of it thrown away —
// which is how a spec that caught the app on its 500 page had nothing to say about why.
faults.observe(context, user?.name ?? "anonymous") faults.observe(context, user?.name ?? "anonymous")
// Playwright matches the most recently registered route first and every mock falls through // Playwright matches the most recently registered route first, so the block-all goes in first.
// what it doesn't recognize, so the block-all goes in before the mocks, and all of it before
// the page navigates.
await installHttpRoutes(context) await installHttpRoutes(context)
await installWebSocketRoutes(context, zooid) await installWebSocketRoutes(context, zooid)
@ -257,6 +240,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
silenceRelay(context, url) silenceRelay(context, url)
} }
for (const url of options.eoseless ?? []) {
withholdEose(context, url)
}
await mockRelayInfo(context, options.relayInfo ?? {}) await mockRelayInfo(context, options.relayInfo ?? {})
await mockAnalytics(context) await mockAnalytics(context)
await mockDufflepud(context) await mockDufflepud(context)
@ -276,9 +263,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
await injectWebLn(context, options.webln) await injectWebLn(context, options.webln)
} }
// Headless Chromium reports `Notification.permission` as "denied" even where playwright has // Headless Chromium reports Notification.permission as "denied" whatever playwright granted.
// granted the permission at the browser level, so the grant is reflected into the API the app
// reads.
if (options.context?.permissions?.includes("notifications")) { if (options.context?.permissions?.includes("notifications")) {
await context.addInitScript(() => { await context.addInitScript(() => {
Object.defineProperty(Notification, "permission", { Object.defineProperty(Notification, "permission", {
@ -291,8 +276,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
return boot(context, { return boot(context, {
user, user,
nip07: Boolean(options.nip07),
path, path,
env: options.env, env: options.env,
checked: options.checked,
relays: urls, relays: urls,
indexers: indexerUrls, indexers: indexerUrls,
spaces: urls, spaces: urls,
@ -311,28 +298,22 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
}, },
}) })
// Closed before anything is read off it: an $effect teardown reading a binding svelte has // Closed before anything is read off it: an $effect teardown reading a cleared binding throws.
// already cleared throws on unmount, and that is the fault the suite was blindest to.
for (const {context} of contexts) { for (const {context} of contexts) {
await context.close() await context.close()
} }
// Before the assertions below, which throw: a test that fails still owes the next one a // Before the assertions below, which throw: a failing test still owes the next one a container.
// container, and with every page already closed there is nothing left for the recreate's
// network churn to interrupt.
await zooid.reset() await zooid.reset()
if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) { if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) {
// A path rather than a body: the list reporter truncates an inline attachment, and the // A path rather than a body, since the list reporter truncates an inline attachment.
// nightly run on the box keeps test-results and nothing else.
const consolePath = testInfo.outputPath("browser-console.log") const consolePath = testInfo.outputPath("browser-console.log")
await writeFile(consolePath, faults.log.join("\n")) await writeFile(consolePath, faults.log.join("\n"))
await testInfo.attach("browser-console", {path: consolePath, contentType: "text/plain"}) await testInfo.attach("browser-console", {path: consolePath, contentType: "text/plain"})
// What each user said to each relay and what came back. The console says what the app did // Whether a user ever had an event, which tells a client that dropped one from a relay that never sent it.
// with an event; this says whether it ever had one, which is the only way to tell a client
// that dropped a message from a relay that never sent it.
const transcriptPath = testInfo.outputPath("relay-transcript.log") const transcriptPath = testInfo.outputPath("relay-transcript.log")
await writeFile( await writeFile(

View file

@ -9,8 +9,7 @@ export type TestUser = {
signer: Nip01Signer signer: Nip01Signer
} }
// Every identity this process can sign as. zooid authenticates each write and refuses an event // zooid refuses an event whose author is not the authenticated pubkey, so seeding looks the author up here.
// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here.
export const testUsersByPubkey = new Map<string, TestUser>() export const testUsersByPubkey = new Map<string, TestUser>()
const makeUser = (name: string, secret: string): TestUser => { const makeUser = (name: string, secret: string): TestUser => {
@ -21,8 +20,7 @@ const makeUser = (name: string, secret: string): TestUser => {
return user return user
} }
// The secrets never leave the test process. They are stable across runs, so a pubkey can be // Stable across runs, so a pubkey can be asserted on and its leading nibbles name an author in a diff.
// asserted on directly, and the leading nibbles name an author in a failed-assertion diff.
export const users = { export const users = {
alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"),
bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"),
@ -30,15 +28,10 @@ export const users = {
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
} }
// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into // secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into it.
// that range rather than rejected when it falls outside.
const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
/** /** An identity beyond the four above, derived from its name so its pubkey is stable, and registered on minting. */
* An identity beyond the four above, named rather than listed. The secret is derived from the name,
* so the pubkey is as stable across runs, and minting one registers it: a scenario can seed for it
* like any other test user.
*/
export const makeTestUser = (name: string) => { export const makeTestUser = (name: string) => {
const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex")) const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex"))
const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0") const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0")

View file

@ -1,8 +1,6 @@
import type {BrowserContext} from "@playwright/test" import type {BrowserContext} from "@playwright/test"
// State the harness keeps per browser context, outside the browser: recorded traffic, blocked // Reading one before the route installer that owns it has run is a spec calling things out of order.
// requests, the hosting fake's store. Reading one before the route installer that owns it has run
// is a spec calling things out of order, which the error names.
export const makeContextStore = <T>(installer: string) => { export const makeContextStore = <T>(installer: string) => {
const byContext = new WeakMap<BrowserContext, T>() const byContext = new WeakMap<BrowserContext, T>()

View file

@ -8,9 +8,7 @@ import {tenantByUrl} from "../zooid/config"
import {requestZooid} from "../zooid/transport" import {requestZooid} from "../zooid/transport"
import {makeContextStore} from "./context" import {makeContextStore} from "./context"
// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because // Mirrors the service urls in src/app/env.ts, which reads import.meta.env and pulls in Capacitor.
// env.ts reads import.meta.env and pulls in Capacitor. These are route patterns rather than urls
// anything fetches, and every handler answers from memory.
const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social"
const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social"
const HOSTING_ORIGIN = "https://api.hosting.coracle.social" const HOSTING_ORIGIN = "https://api.hosting.coracle.social"
@ -21,8 +19,7 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Transcription and speech both go here, against whichever key the user has saved. // Transcription and speech both go here, against whichever key the user has saved.
const OPENROUTER_ORIGIN = "https://openrouter.ai" const OPENROUTER_ORIGIN = "https://openrouter.ai"
// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts // .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
// the navigation, so a spec sees the redirect without one leaving.
const CHECKOUT_ORIGIN = "https://checkout.test" const CHECKOUT_ORIGIN = "https://checkout.test"
// Relay-hosted livekit lives under a well-known path rather than an origin of its own. // Relay-hosted livekit lives under a well-known path rather than an origin of its own.
@ -34,9 +31,7 @@ const PNG = Buffer.from(
"base64", "base64",
) )
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it // The dev server. Traffic to it is the app loading itself, so it is the one host both layers pass.
// is the app loading itself rather than egress, so it is the one host both layers here let past,
// websockets included for Vite's hmr socket.
export const isDevServerUrl = (url: URL) => export const isDevServerUrl = (url: URL) =>
url.port === (process.env.E2E_PORT ?? "1847") && url.port === (process.env.E2E_PORT ?? "1847") &&
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
@ -55,18 +50,11 @@ export type BlockedRequest = {
const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes") const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes")
/** /** Blocks every http request but the dev server's and a relay's own origin. Install this first. */
* Blocks every http request the app makes, except to the dev server and to a relay's own origin,
* which is forwarded to the container so that the nip-11 document and the nip-86 management api the
* app reads are the real relay's. Install this first. The per-service mocks below are registered
* later and therefore take priority, so a request that still reaches this handler is one nothing
* has mocked.
*/
export const installHttpRoutes = (context: BrowserContext) => { export const installHttpRoutes = (context: BrowserContext) => {
const blocked = blockedStore.set(context, []) const blocked = blockedStore.set(context, [])
// The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is // A sveltekit page in dev is hundreds of module requests, and none of them is egress.
// hundreds of module requests, and none of them is egress.
return context.route( return context.route(
url => !isDevServerUrl(url), url => !isDevServerUrl(url),
async route => { async route => {
@ -94,12 +82,7 @@ export const installHttpRoutes = (context: BrowserContext) => {
) )
} }
/** /** Opt-in: some of what a page asks for is meant to be refused, so call it from a spec rather than teardown. */
* Opt-in, for a spec that wants every request the app made to have been answered by something the
* scenario stood up. Some of what a page asks for is meant to be refused, such as the blossom probe
* against a relay with blossom off, so call this from a spec that has mocked what it exercises
* rather than from teardown.
*/
export const assertNoBlockedRequests = (context: BrowserContext) => { export const assertNoBlockedRequests = (context: BrowserContext) => {
const blocked = blockedStore.get(context) const blocked = blockedStore.get(context)
@ -116,14 +99,7 @@ export const assertNoBlockedRequests = (context: BrowserContext) => {
// Fields to merge over a relay's own nip-11 document, keyed by its url. // Fields to merge over a relay's own nip-11 document, keyed by its url.
export type RelayInfoOverrides = Record<string, object> export type RelayInfoOverrides = Record<string, object>
/** /** A relay's real document with a few fields replaced. It is read at startup, so install it before navigating. */
* A relay's real document with a few fields replaced, such as a `redirect_to`, a `limitation`, or a
* NIP the relay does not implement. It is merged rather than fabricated because `self`, `pubkey`,
* `name` and `supported_nips` are what room state is trusted from, and 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) => { export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
const overrideByOrigin = new Map( const overrideByOrigin = new Map(
Object.entries(overrides).map(([url, override]): [string, object] => [ Object.entries(overrides).map(([url, override]): [string, object] => [
@ -140,8 +116,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr
const host = hostByOrigin.get(origin) const host = hostByOrigin.get(origin)
const override = overrideByOrigin.get(origin) const override = overrideByOrigin.get(origin)
// The nip-86 management api posts to this same path, and what it answers decides what the // The nip-86 management api posts to this same path, so only the document request is touched.
// admin ui offers, so only the document request is touched.
if (request.method() === "GET" && host && override) { if (request.method() === "GET" && host && override) {
const {status, headers, body} = await requestZooid(host, "GET", "/", { const {status, headers, body} = await requestZooid(host, "GET", "/", {
...request.headers(), ...request.headers(),
@ -151,8 +126,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr
return route.fulfill({ return route.fulfill({
status, status,
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's headers are kept.
// headers are kept, all but the length of a body that is about to change.
headers: omit(["content-length"], headers), headers: omit(["content-length"], headers),
body: JSON.stringify({...JSON.parse(body.toString()), ...override}), body: JSON.stringify({...JSON.parse(body.toString()), ...override}),
}) })
@ -163,11 +137,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr
) )
} }
/** /** The analytics script src/app.html loads. An empty body leaves window.plausible as the queueing shim. */
* The analytics script src/app.html loads on every page. Answering with an empty body leaves
* `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate in
* memory and nothing is ever sent.
*/
export const mockAnalytics = (context: BrowserContext) => export const mockAnalytics = (context: BrowserContext) =>
context.route(`${PLAUSIBLE_ORIGIN}/**`, route => context.route(`${PLAUSIBLE_ORIGIN}/**`, route =>
route.fulfill({contentType: "application/javascript", body: ""}), route.fulfill({contentType: "application/javascript", body: ""}),
@ -183,12 +153,7 @@ export type DufflepudFixtures = {
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[] zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
} }
/** /** Dufflepud, the one service url the app hard-codes. `as()` installs it with no fixtures, so a spec calls this again. */
* 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 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 = {}) => export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
const {pathname} = new URL(route.request().url()) const {pathname} = new URL(route.request().url())
@ -214,12 +179,10 @@ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtur
return route.fallback() return route.fallback()
}) })
// The rate the harness writes its silence at. Nothing downstream reads it: the app decodes what it // The app decodes what it is answered and writes its own header from the result.
// is answered and writes its own header from the result.
const SPEECH_RATE = 24000 const SPEECH_RATE = 24000
// Silence as a wav, built rather than inlined so a spec can ask for a length and then assert the // Built rather than inlined, so a spec can ask for a length and assert the duration read off it.
// duration the player reads off it.
const silence = (seconds: number) => { const silence = (seconds: number) => {
const bytes = seconds * SPEECH_RATE * 2 const bytes = seconds * SPEECH_RATE * 2
const wav = Buffer.alloc(44 + bytes) const wav = Buffer.alloc(44 + bytes)
@ -240,16 +203,10 @@ const silence = (seconds: number) => {
return wav return wav
} }
// The format the app asks the real endpoint for. The harness has no mp3 encoder, so it answers a // The harness has no mp3 encoder, so it answers a wav, which the app decodes either way.
// wav instead — the app decodes either, and only an answer a browser can decode exercises that.
const SPEECH_FORMAT = "mp3" const SPEECH_FORMAT = "mp3"
/** /** OpenRouter's text to speech, answering every request with the same silence. Raw pcm is refused. */
* OpenRouter's text to speech, answering every request with the same silence. The array it returns
* collects what the app asked to have read, in the order it asked. Asking for raw pcm is refused
* the way the real endpoint refuses an unknown format, since pcm is the one answer whose sample
* rate and sample format the app would have to guess at.
*/
export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => { export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => {
const spoken: string[] = [] const spoken: string[] = []
@ -272,18 +229,14 @@ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3)
} }
export type Transcription = { export type Transcription = {
// The name each recording was uploaded under, in the order it was uploaded. OpenRouter picks a // OpenRouter picks a decoder off the extension, so this is how a spec reads what the recorder made.
// decoder off the extension, so this is how a spec reads what the recorder produced.
uploads: string[] uploads: string[]
// Stops answering. A request that arrives while this is on is held open until `release`, which // Stops answering. A request that arrives while this is on is held open until `release`.
// is what a spec needs to watch a transcription that is still out.
hold(): void hold(): void
release(): void release(): void
} }
/** /** OpenRouter's speech to text, answering every recording with the same transcript. */
* OpenRouter's speech to text, answering every recording with the same transcript.
*/
export const mockOpenRouterTranscription = async ( export const mockOpenRouterTranscription = async (
context: BrowserContext, context: BrowserContext,
text: string, text: string,
@ -317,9 +270,7 @@ export const mockOpenRouterTranscription = async (
} }
} }
// Where an upload lands when nothing else is configured. getBlossomServer probes the space's own // getBlossomServer probes the space's own origin, then the user's kind-10063 list, then this.
// 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.
export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net" export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net"
export type BlossomOptions = { export type BlossomOptions = {
@ -327,27 +278,18 @@ export type BlossomOptions = {
server: string server: string
} }
/** /** What a spec can turn on. Each of these is off by default. */
* What a spec can turn on. Each of these is off by default, so a scenario that only needs somewhere
* for an upload to land ignores all of it.
*/
export type BlossomHandle = { export type BlossomHandle = {
// The same server, blobs included, in another user's context. A conversation's image is uploaded // A conversation's image is uploaded encrypted, so one end's bytes have to be the other's.
// encrypted and decrypted by the recipient, so the bytes one end puts in have to be the bytes the
// other reads back.
install(context: BrowserContext): Promise<void> install(context: BrowserContext): Promise<void>
// Holds every upload open until `release`, which makes the in-flight state a fact rather than a // Holds every upload open until `release`, which makes the in-flight state a fact rather than a race.
// race against a mock that answers in a microtask.
hold(): void hold(): void
release(): void release(): void
// Refuses every upload probe from here on, with a reason the app has to show. // Refuses every upload probe from here on, with a reason the app has to show.
refuse(reason: string): void refuse(reason: string): void
} }
/** /** A blossom server that keeps what it was given, hashing an upload exactly as the real thing would. */
* A blossom server that keeps what it was given. An upload is hashed exactly as the real thing
* would be, so the descriptor it answers with points at a blob this mock can then serve back.
*/
export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => { export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => {
const {origin} = new URL(server) const {origin} = new URL(server)
const blobs = new Map<string, {body: Buffer; type: string}>() const blobs = new Map<string, {body: Buffer; type: string}>()
@ -364,8 +306,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti
const {pathname} = new URL(request.url()) const {pathname} = new URL(request.url())
if (pathname === "/upload") { if (pathname === "/upload") {
// BUD-06, which the app asks before spending an upload. A refusal has to expose its // BUD-06, which the app asks before spending an upload. A refusal exposes its reason cross-origin.
// reason cross-origin or all the toast can say is the status code.
if (method === "HEAD") { if (method === "HEAD") {
if (refusal) { if (refusal) {
return route.fulfill({ return route.fulfill({
@ -424,10 +365,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti
return handle return handle
} }
/** /** The push server. Only the native adapters talk to it, so a browser run reaching this is a regression. */
* The push server from src/app/push/adapters/capacitor.ts. Only the native adapters talk to it,
* so a browser run reaches this only if the platform detection regresses.
*/
export const mockPushServer = (context: BrowserContext) => export const mockPushServer = (context: BrowserContext) =>
context.route(`${PUSH_SERVER_ORIGIN}/**`, route => { context.route(`${PUSH_SERVER_ORIGIN}/**`, route => {
const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean) const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean)
@ -437,8 +375,7 @@ export const mockPushServer = (context: BrowserContext) =>
return route.fulfill({json: {}}) return route.fulfill({json: {}})
} }
// Registration is only usable if it comes back with both. The relay is told to post // The relay is told to post notifications to the callback rather than the client ever fetching it.
// notifications to the callback rather than the client ever fetching it.
const key = "test-push-subscription" const key = "test-push-subscription"
return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}}) return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}})
@ -447,26 +384,20 @@ export const mockPushServer = (context: BrowserContext) =>
return route.fallback() return route.fallback()
}) })
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left // One record off the hosting api, left loose so mocking a flow doesn't pull import.meta.env in.
// 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> export type HostingRecord = Record<string, unknown>
// The backend's state when the page opens. Everything after that is what the app wrote, plus // The backend's state when the page opens.
// whatever the handle below changed.
export type HostingFixtures = { export type HostingFixtures = {
plans?: HostingRecord[] plans?: HostingRecord[]
// Provisioning runs on every login and ignores what it gets back, so the empty default carries // Provisioning runs on every login and ignores what it gets back.
// any spec that isn't actually exercising the hosting ui.
tenant?: HostingRecord tenant?: HostingRecord
relays?: HostingRecord[] relays?: HostingRecord[]
invoices?: HostingRecord[] invoices?: HostingRecord[]
draftInvoice?: HostingRecord draftInvoice?: HostingRecord
} }
// The backend changing its mind between two of the user's clicks, such as a custom domain that // The backend changing its mind between two of the user's clicks, which no click can reach.
// verifies or an invoice that gets paid. It is the half of those flows no click can reach.
export type HostingHandle = { export type HostingHandle = {
setTenant(patch: HostingRecord): void setTenant(patch: HostingRecord): void
setRelay(id: string, patch: HostingRecord): void setRelay(id: string, patch: HostingRecord): void
@ -477,12 +408,7 @@ const hostingStore = makeContextStore<HostingHandle>("mockHosting")
export const getHosting = (context: BrowserContext) => hostingStore.get(context) export const getHosting = (context: BrowserContext) => hostingStore.get(context)
/** /** The hosting api as a small stateful fake. Each browser context gets its own store. */
* 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. Each browser context gets its own store, so one user's spaces
* are not another's.
*/
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => { export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
const plans = fixtures.plans ?? [] const plans = fixtures.plans ?? []
const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay])) const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay]))
@ -568,8 +494,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
custom_domain: "", custom_domain: "",
custom_domain_verified: 0, custom_domain_verified: 0,
...body, ...body,
// A created space is opened straight away, so its url has to be one the container // A created space is opened straight away, so its url has to be one the container serves.
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
zooid_domain: body.zooid_domain || "test", zooid_domain: body.zooid_domain || "test",
} }
@ -592,8 +517,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
return route.fulfill({json: {data: {activity: relay.activity ?? []}}}) return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
} }
// The dump is whatever the fixture left on the record, served as the download itself rather // The dump is served as the download itself rather than in a json envelope.
// than in a json envelope.
if (sub === "export") { if (sub === "export") {
return route.fulfill({ return route.fulfill({
contentType: "application/x-ndjson", contentType: "application/x-ndjson",
@ -601,8 +525,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
}) })
} }
// Storing events is the relay's own business, so the fake counts the lines and refuses the // The fake counts the lines and refuses the ones that don't look signed, as zooid reports them.
// ones that don't look signed, which is the shape zooid reports problems in.
if (sub === "import") { if (sub === "import") {
const lines = (request.postData() ?? "").split("\n").filter(line => line.trim()) const lines = (request.postData() ?? "").split("\n").filter(line => line.trim())
const problems = lines.flatMap((line, index) => const problems = lines.flatMap((line, index) =>
@ -659,8 +582,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}}) 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 // GET and reconcile both answer with the invoice as it stands, which is how a spec marks one paid.
// 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.fulfill({json: {data: invoice}})
} }
@ -671,8 +593,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
} }
export type LivekitOptions = { export type LivekitOptions = {
// Where the client is told to connect. Point it at something the test owns, since the token this // Point it at something the test owns, since the token this hands out is accepted by nothing else.
// hands out is accepted by nothing else.
serverUrl: string serverUrl: string
token?: string token?: string
} }
@ -694,10 +615,30 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
}, },
) )
/** // One row of Photon's answer. Anything but a city, state or country is left out of the results.
* Serves a png for anything the browser is loading as an image, so a scenario's fixtures can export type GeocodedPlace = {
* reference avatar, banner, blob and poster urls without any of them being fetched. type: string
*/ name: string
state?: string
country?: string
latitude: number
longitude: number
}
/** The place search behind the profile's location field, answering every term with the same places. */
export const mockGeocoding = (context: BrowserContext, places: GeocodedPlace[]) =>
context.route("https://photon.komoot.io/**", route =>
route.fulfill({
json: {
features: places.map(({type, name, state, country, latitude, longitude}) => ({
geometry: {coordinates: [longitude, latitude]},
properties: {type, name, state, country},
})),
},
}),
)
/** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */
export const mockImages = (context: BrowserContext) => export const mockImages = (context: BrowserContext) =>
context.route( context.route(
url => !isDevServerUrl(url), url => !isDevServerUrl(url),

View file

@ -28,12 +28,12 @@ type Traffic = {
leaks: Set<string> leaks: Set<string>
forgotten: Set<string> forgotten: Set<string>
silenced: Set<string> silenced: Set<string>
eoseless: Set<string>
} }
const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes") const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes")
// A relay that holds nothing. REQs get an immediate EOSE and events are accepted and dropped, so a // A relay that holds nothing, so a leak fails on the assertion naming it rather than on a timeout.
// leak fails on the assertion that names it rather than on a timeout three layers away.
const openEmptyRelay = (): RelayConnection => { const openEmptyRelay = (): RelayConnection => {
let listener: (message: RelayMessage) => void = () => undefined let listener: (message: RelayMessage) => void = () => undefined
@ -52,9 +52,20 @@ const openEmptyRelay = (): RelayConnection => {
} }
} }
// A relay that takes the socket and then says nothing at all: no events, no eose, no closed. It is // A relay that serves what it has and never says it is done, so a request runs out its own deadline.
// the fault a client cannot see, since a request it never answers is indistinguishable from one it const openEoselessRelay = (connection: RelayConnection): RelayConnection => ({
// is still working on, and the only way out is the caller's own deadline. onMessage(listener) {
connection.onMessage(message => {
if (message[0] !== RelayMessageType.Eose) {
listener(message)
}
})
},
send: message => connection.send(message),
close: () => connection.close(),
})
// A relay that takes the socket and says nothing, so the only way out is the caller's own deadline.
const openSilentRelay = (): RelayConnection => ({ const openSilentRelay = (): RelayConnection => ({
onMessage() {}, onMessage() {},
send() {}, send() {},
@ -75,7 +86,9 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
const relay = zooid.relays.get(url) const relay = zooid.relays.get(url)
if (relay) { if (relay) {
return relay.connect() const connection = relay.connect()
return traffic.eoseless.has(url) ? openEoselessRelay(connection) : connection
} }
traffic.leaks.add(url) traffic.leaks.add(url)
@ -100,21 +113,14 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
route.onClose(() => connection.close()) route.onClose(() => connection.close())
} }
/** /** The single interception point for relay traffic. On the context, so it covers a page opened later. */
* The single interception point for relay traffic. It goes on the context rather than a page, so
* every page in it is covered including ones opened later, and it is safe to call before any page
* exists.
*
* Vite's hmr socket is the one url left alone. Everything else is answered from this process, and
* a url that is not one of the container's virtual relays is served by an empty relay and recorded
* as a leak.
*/
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => { export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
const traffic = trafficStore.set(context, { const traffic = trafficStore.set(context, {
transcript: [], transcript: [],
leaks: new Set(), leaks: new Set(),
forgotten: new Set(), forgotten: new Set(),
silenced: new Set(), silenced: new Set(),
eoseless: new Set(),
}) })
return context.routeWebSocket( return context.routeWebSocket(
@ -125,8 +131,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript
// Every event this context put on the wire, oldest first, with the relay it went to. One event // Every event this context put on the wire, oldest first. One event on three relays is three entries.
// published to three relays is three entries.
export const getPublished = (context: BrowserContext): PublishedEvent[] => export const getPublished = (context: BrowserContext): PublishedEvent[] =>
getTranscript(context) getTranscript(context)
.filter( .filter(
@ -134,28 +139,25 @@ export const getPublished = (context: BrowserContext): PublishedEvent[] =>
) )
.map(({url, message}) => ({url, event: message[1] as TrustedEvent})) .map(({url, message}) => ({url, event: message[1] as TrustedEvent}))
// The same, narrowed to one kind, which is how a spec asks what the client published rather than // The same, narrowed to one kind, for asking what the client published rather than what it rendered.
// what it rendered.
export const getPublishedEvents = (context: BrowserContext, kind: number) => export const getPublishedEvents = (context: BrowserContext, kind: number) =>
getPublished(context) getPublished(context)
.filter(({event}) => event.kind === kind) .filter(({event}) => event.kind === kind)
.map(({event}) => event) .map(({event}) => event)
// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve` // `serve` resolves a relay once, at open, so a socket already open keeps its relay until a reload.
// resolves a relay once, at open, so sockets already open keep theirs and the drop takes effect on
// the next connection. A reload is what gives it one.
export const forgetRelay = (context: BrowserContext, url: string) => export const forgetRelay = (context: BrowserContext, url: string) =>
trafficStore.get(context).forgotten.add(normalizeRelayUrl(url)) trafficStore.get(context).forgotten.add(normalizeRelayUrl(url))
// Makes a relay answer nothing at all, without its url becoming a leak. Silence is all this relay // Resolved at open, like forgetRelay, so a page that boots into the fault passes `silent` to `as`.
// is, so it needs no tenant behind it: the url never reaches the container, and naming it here is
// what says the app was meant to open it. Resolved at open, like `forgetRelay`, so a spec that
// wants a page to boot into the fault passes `silent` to `as` instead of calling this.
export const silenceRelay = (context: BrowserContext, url: string) => export const silenceRelay = (context: BrowserContext, url: string) =>
trafficStore.get(context).silenced.add(normalizeRelayUrl(url)) trafficStore.get(context).silenced.add(normalizeRelayUrl(url))
// Every frame in both directions, oldest first. Attach it to a failing test to see what the client // Resolved at open too, so a page that boots into it passes `eoseless` to `as`.
// actually said, and to whom. export const withholdEose = (context: BrowserContext, url: string) =>
trafficStore.get(context).eoseless.add(normalizeRelayUrl(url))
// Every frame in both directions, oldest first. Attach it to a failing test.
export const formatTranscript = (context: BrowserContext) => export const formatTranscript = (context: BrowserContext) =>
getTranscript(context) getTranscript(context)
.map( .map(

View file

@ -8,11 +8,7 @@ import type {TestUser} from "../keys"
import {makePublisher} from "./publish" import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish" import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
/** /** One public relay's fixtures. Unlike a space it holds what a client reaches for by pubkey rather than by space. */
* One public relay's fixtures. Unlike a space it has no rooms, no members and nothing behind an
* `h` tag: it holds the things a client reaches for by pubkey rather than by space — a relay list,
* a follow list, a profile, a note.
*/
export type SeededOpenRelay = { export type SeededOpenRelay = {
readonly name: OpenRelayName readonly name: OpenRelayName
// Known before seeding runs, since a relay list has to name the relay a note is seeded on. // Known before seeding runs, since a relay list has to name the relay a note is seeded on.
@ -21,14 +17,11 @@ export type SeededOpenRelay = {
readonly relayLists: SeededEvent[] readonly relayLists: SeededEvent[]
note(user: TestUser, content: string, createdAt?: number): SeededEvent note(user: TestUser, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
// Where this user reads and writes. A relay a scenario expects the client to read from has to // A relay a scenario expects the client to read from has to appear in the reader's own list.
// appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only
// identifies to relays that list names.
relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent
follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
// This relay's domain kinds, bound to a resolver that answers with its url, the same way a // This relay's domain kinds, bound to a resolver that answers with its url.
// space's `kind()` does.
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>( kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>, factory: KindFactory<R, W, Q>,
): ConfiguredKind<R, W, Q> ): ConfiguredKind<R, W, Q>

View file

@ -5,20 +5,16 @@ import type {TestUser} from "../keys"
// A queued write, drained in declaration order by `seed` in scenario.ts. // A queued write, drained in declaration order by `seed` in scenario.ts.
export type Enqueue = (write: () => Promise<void>) => void export type Enqueue = (write: () => Promise<void>) => void
// A handle to an event the scenario is going to publish. Seeding calls record what to write and // Seeding calls record what to write and return before anything is written.
// return before anything is written, so the event is filled in when its turn in the queue comes up.
export type SeededEvent = { export type SeededEvent = {
readonly event: SignedEvent readonly event: SignedEvent
readonly id: string readonly id: string
} }
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs // A domain writer needs a relay url to resolve its hints against, so anything it builds is deferred.
// a relay url to resolve its hints against, and a relay has none until the queue has drained, so
// anything built by one has to be deferred.
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>) export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load // `write` is what an outbox-routed load resolves to, `read` is what a feed asks for that pubkey.
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
export type RelayListUrls = { export type RelayListUrls = {
read?: string[] read?: string[]
write?: string[] write?: string[]
@ -35,17 +31,13 @@ export type PublisherOptions = {
// The relay these events are seeded into, for the error a read-too-early raises. // The relay these events are seeded into, for the error a read-too-early raises.
name: string name: string
enqueue: Enqueue enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it // The moment the scenario began. A fixture with no timestamp is stamped with it, not the wall clock.
// rather than with the wall clock, so two fixtures describing the same thing cannot land
// seconds apart and decide which of them wins.
startedAt: number startedAt: number
// How an event this process signs reaches the relay, deferred because a space's relay handle // Deferred, since a space's relay handle only exists once the queue has started draining.
// only exists once the queue has started draining.
sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent> sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent>
} }
// The queue every seeding call goes through: what to write is recorded now and published when // What to write is recorded now and published when `seed()` drains.
// `seed()` drains, and what it produced only reads back after that.
export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => { export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => {
// Queues a write and hands back a getter for whatever it produced. // Queues a write and hands back a getter for whatever it produced.
const seeded = <T>(write: () => Promise<T>) => { const seeded = <T>(write: () => Promise<T>) => {

View file

@ -11,16 +11,13 @@ import type {SeededSpace} from "./space"
import {seedOpenRelay} from "./openRelay" import {seedOpenRelay} from "./openRelay"
import type {SeededOpenRelay} from "./openRelay" import type {SeededOpenRelay} from "./openRelay"
// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two // A fixture timestamp as an offset from the scenario's start. `at(2, HOUR)` is two hours before it.
// hours before the test began, count-first like int and ago.
export type At = (count: number, unit: number) => number export type At = (count: number, unit: number) => number
export type SeedTools = { export type SeedTools = {
// Names a space the container already serves. Its policy is its toml in zooid/docker/config. // Names a space the container already serves. Its policy is its toml in zooid/docker/config.
relay: (name: SpaceName) => SeededSpace relay: (name: SpaceName) => SeededSpace
// Names one of the public relays, which is where the follow graph lives: `indexer` is what a // Names one of the public relays, where the follow graph lives. See ARCHITECTURE.md, "The follow graph".
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
open: (name: OpenRelayName) => SeededOpenRelay open: (name: OpenRelayName) => SeededOpenRelay
user: typeof users user: typeof users
at: At at: At
@ -31,14 +28,11 @@ export type Scenario = {
readonly at: At readonly at: At
// The spaces this scenario seeded, which are the relays the app is handed as its own. // The spaces this scenario seeded, which are the relays the app is handed as its own.
readonly urls: string[] readonly urls: string[]
// What a pubkey's own lists are resolved from: the open indexer when a scenario declared one, // The open indexer when a scenario declared one, and the spaces otherwise.
// and the spaces otherwise, which is what a scenario that knows nothing about open relays gets.
readonly indexerUrls: string[] readonly indexerUrls: string[]
space(name: SpaceName): SeededSpace space(name: SpaceName): SeededSpace
open(name: OpenRelayName): SeededOpenRelay open(name: OpenRelayName): SeededOpenRelay
// The events a returning user's client would already have on disk: their room list, and their // A relay won't serve the list that would tell authPolicy it may identify to it.
// relay list when the scenario seeded one. A relay won't serve the list that would tell
// authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions".
cache(user: TestUser): SignedEvent[] cache(user: TestUser): SignedEvent[]
} }
@ -74,16 +68,14 @@ export const seed = async (
await build({relay, open, user: users, at}) await build({relay, open, user: users, at})
// Seeding is async and fixtures depend on one another, so the builder only records what to // Fixtures depend on one another, so the builder only records what to write and this drains it.
// write. Draining the queue here publishes each fixture in the order it was declared.
for (const write of writes) { for (const write of writes) {
await write() await write()
} }
const seeded = Array.from(spaces.values()) const seeded = Array.from(spaces.values())
// A room list is replaceable and covers every space at once, so it can only be written after // A room list is replaceable and covers every space, so it is written after all of them are seeded.
// all of them have been seeded.
const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>() const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>()
for (const space of seeded) { for (const space of seeded) {

View file

@ -23,20 +23,16 @@ import type {TestUser} from "../keys"
import {makePublisher} from "./publish" import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish" import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one, // @welshman/domain has no writer for NIP-29 kind-9 messages, so this pairs the base writer and reader.
// so this pairs the base writer with the base reader. The behavior tags it renders are everything a
// room message carries: `h` via setRoom, `q` and `p` via addQuote and addMention.
class MessageWriter extends EventWriter<BaseEventReader> {} class MessageWriter extends EventWriter<BaseEventReader> {}
// The kind-14 a direct message really is. It is never published, since each participant gets it // The kind-14 a direct message really is. It is never published, since each participant gets a wrap.
// inside a gift wrap, so this is what a spec asserts on.
export type SeededRumor = { export type SeededRumor = {
readonly rumor: HashedEvent readonly rumor: HashedEvent
readonly id: string readonly id: string
} }
// A user's membership as their own client sees it, which the scenario turns into one room list // A user's membership as their own client sees it, turned into a room list once every space is seeded.
// per user once every space has been seeded.
export type SeededMembership = { export type SeededMembership = {
user: TestUser user: TestUser
rooms: string[] rooms: string[]
@ -48,26 +44,19 @@ export type SeededSpace = {
readonly memberships: SeededMembership[] readonly memberships: SeededMembership[]
room(h: string, options?: RoomOptions): void room(h: string, options?: RoomOptions): void
member(user: TestUser, h?: string): void member(user: TestUser, h?: string): void
// Relay and room membership, plus a place in the user's own room list, which is what a user who // Relay and room membership, plus a place in the user's own room list.
// joined this space through the ui ends up with.
join(user: TestUser, ...rooms: string[]): void join(user: TestUser, ...rooms: string[]): void
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
// Where this user reads and writes, this space by default. Outbox routing resolves everything // Outbox routing resolves everything about a person through their relay list.
// about a person through their relay list — their profile, the events they authored, the wraps
// addressed to them — so a fixture is only loadable by somebody else once its author has one.
relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent
// The kind-10050 that says where someone's direct messages go, this space by default. Having one // Having one is what makes a person reachable, so messaging being off is a person without one.
// is what makes a person reachable, so a story about messaging being off is a person without one.
messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
// A nip-17 conversation. One kind-14 rumor, gift-wrapped once per participant including the // One kind-14 rumor, gift-wrapped once per participant including the sender.
// sender, whose own copy is the half of the thread their client reads back.
dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor 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 // This space's domain kinds, bound to a resolver that answers with its url.
// 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>, Q extends EventQuery>( kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>, factory: KindFactory<R, W, Q>,
): ConfiguredKind<R, W, Q> ): ConfiguredKind<R, W, Q>
@ -76,16 +65,14 @@ export type SeededSpace = {
export type SeedSpaceOptions = { export type SeedSpaceOptions = {
zooid: Zooid zooid: Zooid
enqueue: Enqueue enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it // A fixture declared without a timestamp is stamped with the scenario's start, not the wall clock.
// rather than with the wall clock.
startedAt: number startedAt: number
name: SpaceName name: SpaceName
} }
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
const memberships: SeededMembership[] = [] const memberships: SeededMembership[] = []
// Known before seeding runs, unlike the relay handle below, so a fixture on another relay can // Known before seeding runs, unlike the relay handle below, so another relay's fixture can name it.
// name this one.
const url = tenantUrl(name) const url = tenantUrl(name)
let testRelay: Maybe<TestRelay> let testRelay: Maybe<TestRelay>
@ -138,9 +125,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
const message = (user: TestUser, h: string, content: string, createdAt = startedAt) => const message = (user: TestUser, h: string, content: string, createdAt = startedAt) =>
publish(() => relay().message(user, h, content, createdAt)) publish(() => relay().message(user, h, content, createdAt))
// Flotilla replies in a room by quoting. Content.svelte renders a quote from the nostr uri in the // Content.svelte renders a quote from the nostr uri rather than the q tag, as prependParent does.
// content rather than from the q tag, so the uri is prepended as prependParent does in
// src/app/rooms.ts.
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
event( event(
user, user,
@ -188,9 +173,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) => const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) =>
event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt) event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt)
// Every wrap is published over the sender's own connection, since a gift wrap's author is an // A gift wrap's author is an ephemeral key nobody here can authenticate as, so the sender sends it.
// ephemeral key nobody in this process can authenticate as. zooid stores it anyway, authorizing a
// kind-1059 by the member named in its p tag.
const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => { const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => {
const rumor = seeded(async () => { const rumor = seeded(async () => {
const writer = kind(DirectMessage).writer().setContent(content) const writer = kind(DirectMessage).writer().setContent(content)

View file

@ -1,48 +1,34 @@
import {expect} from "@playwright/test" import {expect} from "@playwright/test"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
/** /** What a spec names on screen. A locator more than one spec reaches for belongs here. */
* What a spec names on screen. A locator more than one spec reaches for belongs here, so a class or
* a label the app renames is one edit rather than six — and so the comment explaining why a
* locator is shaped the way it is has one copy to keep true.
*/
export const dialog = (page: Page, title: string) => export const dialog = (page: Page, title: string) =>
page.getByRole("dialog", {name: title, exact: true}) page.getByRole("dialog", {name: title, exact: true})
// The modal on top, for one with no heading of its own or one pushed over another rather than // The modal on top, for one with no heading of its own or one pushed over another.
// alongside it.
export const topDialog = (page: Page) => page.getByRole("dialog").last() export const topDialog = (page: Page) => page.getByRole("dialog").last()
// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit // A modal is mounted alongside the page it covers, so both forms are in the dom at once.
// are both in the dom at once. Anything said about the form is scoped to the modal's own to say
// which one is meant.
export const modalForm = (page: Page, title: string) => export const modalForm = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})}) page.locator("form").filter({has: page.getByRole("heading", {name: title})})
// Named rather than counted into the join EventActions renders, since a card is free to put a join // Named rather than counted, since a card is free to put a join of its own above it.
// of its own above it — a calendar event's rsvp buttons are one.
export const emojiButton = (scope: Locator) => scope.getByRole("button", {name: "Add a reaction"}) export const emojiButton = (scope: Locator) => scope.getByRole("button", {name: "Add a reaction"})
// The menu is the one action with no accessible name, and the last thing in the last join a card // The menu is the one action with no accessible name, and the last thing in a card's last join.
// has.
export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last() export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last()
// The picker is a web component with an open shadow root, so its search field and its results are // The picker is a web component with an open shadow root, and searching avoids its category tabs.
// reachable through it. Searching rather than browsing avoids depending on which category tab an
// emoji happens to live under.
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => { export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
await opener.click() await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true}) 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 // Tippy keeps a hidden popover mounted through its fade, so the last card's picker is still up.
// 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) await expect(picker).toHaveCount(1)
// A result's label is the emoji's name, its annotation and every shortcode joined together, so // A result's label joins the emoji's name, its annotation and every shortcode.
// the annotation is matched rather than the whole of it.
await picker.locator("input.search").fill(annotation) await picker.locator("input.search").fill(annotation)
await picker await picker
.getByRole("option", {name: new RegExp(annotation)}) .getByRole("option", {name: new RegExp(annotation)})
@ -61,8 +47,7 @@ export const roomLink = (page: Page, name: string) =>
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast. // One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
export const toast = (page: Page) => page.getByRole("alert") export const toast = (page: Page) => page.getByRole("alert")
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the // FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row.
// label at one end and the control at the other.
export const settingRow = (page: Page, label: string) => export const settingRow = (page: Page, label: string) =>
page.locator("div.items-center.justify-between").filter({hasText: label}) page.locator("div.items-center.justify-between").filter({hasText: label})
@ -71,9 +56,7 @@ export const settingToggle = (page: Page, label: string) =>
export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The editor is where the composer says whether it is ready. The send button is not there to ask // Only the conversation composer has a disabled state; a room's is usable as soon as it renders.
// while the composer is empty, since a dictation button stands in its place. Only the conversation
// composer has a disabled state; a room's is usable as soon as it renders.
export const composerEnabled = (page: Page) => export const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false") expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
@ -85,8 +68,7 @@ export const sendButton = (page: Page) => page.locator("button[data-tip$='enter
export const timeline = (page: Page) => page.locator(".room__content") export const timeline = (page: Page) => page.locator(".room__content")
// .room__content is column-reverse, so the message at the bottom of the room is the first one in // .room__content is column-reverse, so the message at the bottom of the room is first in the dom.
// the dom.
export const messages = (page: Page) => page.locator(".room__item") export const messages = (page: Page) => page.locator(".room__item")
export const message = (page: Page, text: string) => messages(page).filter({hasText: text}) export const message = (page: Page, text: string) => messages(page).filter({hasText: text})
@ -100,11 +82,7 @@ export const openMessageMenu = (page: Page, text: string) =>
export const bubble = (page: Page, text: string) => export const bubble = (page: Page, text: string) =>
page.locator(".chat-bubble").filter({hasText: text}) page.locator(".chat-bubble").filter({hasText: text})
// Typing into a room's composer and sending it. The composer is waited for rather than assumed, // Clicking a composer that already holds the caret would collapse the selection an edit types over.
// since a room still rendering has none, and clicked into when the caret is somewhere else, since
// typing goes wherever it is. One that already holds the caret is typed into as it stands: clicking
// would collapse the selection, and selecting the whole of it is how an edit types over the message
// it replaces.
export const send = async (page: Page, content: string) => { export const send = async (page: Page, content: string) => {
const editor = composer(page) const editor = composer(page)
@ -125,13 +103,11 @@ export const chatList = (page: Page) => page.locator(".secondary-nav .overflow-a
// One conversation in the sidebar list is one button; nothing inside it is one. // One conversation in the sidebar list is one button; nothing inside it is one.
export const chatItems = (page: Page) => chatList(page).locator("button") export const chatItems = (page: Page) => chatList(page).locator("button")
// The comment and thread composers are rich text rather than chat editors, so they carry a // The comment and thread composers are rich text rather than chat editors.
// different one.
export const noteEditor = (scope: Locator | Page) => export const noteEditor = (scope: Locator | Page) =>
scope.locator(".note-editor [contenteditable=true]") scope.locator(".note-editor [contenteditable=true]")
// A date as the browser formatted it rather than as node would, so the locale and the timezone are // A date as the browser formatted it. The options mirror dateFormatter in @welshman/lib.
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
export const longDate = (page: Page, seconds: number) => export const longDate = (page: Page, seconds: number) =>
page.evaluate( page.evaluate(
ts => ts =>

View file

@ -1,32 +1,16 @@
/** // The virtual relays the container serves. .test is reserved by rfc 2606, so none of them resolve.
* The virtual relays the container serves, and the only place their names are written down.
*
* zooid binds a config to a Host header and serves any number of them from one process, so another
* relay costs a toml in docker/config and nothing else. The names are a union rather than a string
* so that a scenario naming a relay that has no config fails to compile instead of hanging on a 404
* from the dispatcher.
*
* Each host resolves nowhere, and `.test` is reserved by rfc 2606, so a url that escapes this
* process fails to connect rather than reaching a host. transport.ts covers why the container is
* told to call itself this rather than its loopback address.
*/
// The relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla // The relays a space is seeded on: members-only, with nip-29 groups on.
// space is.
export const spaceTenants = { export const spaceTenants = {
space: "space.test", space: "space.test",
other: "other.test", other: "other.test",
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, // Policy space.toml cannot express at the same time: no invite, stripped signatures, delegated management.
// `unsigned` serves events with their signatures stripped, `delegated` gives every member one
// management method.
closed: "closed.test", closed: "closed.test",
unsigned: "unsigned.test", unsigned: "unsigned.test",
delegated: "delegated.test", delegated: "delegated.test",
} as const } as const
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a // Public relays with no groups, where anything outside a space lives. See ARCHITECTURE.md, "The follow graph".
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
export const openTenants = { export const openTenants = {
indexer: "indexer.test", indexer: "indexer.test",
outbox: "outbox.test", outbox: "outbox.test",

View file

@ -20,39 +20,27 @@ import type {PublishOptions} from "./types"
import {connectToZooid, port, requestZooid} from "./transport" import {connectToZooid, port, requestZooid} from "./transport"
import type {ZooidConnection} from "./transport" import type {ZooidConnection} from "./transport"
// The relay every test runs against, pinned by digest so a spec is checked against one zooid rather // Pinned by digest so a spec runs against one zooid. ZOOID_IMAGE is how an unreleased build is tried.
// than whatever this machine last pulled. The tag is there to read; docker resolves the digest, and
// `docker pull` of a newer tag prints the digest to paste beside it. A zooid built from a checkout
// has no published digest, so ZOOID_IMAGE is how an unreleased one is tried.
const image = const image =
process.env.ZOOID_IMAGE ?? process.env.ZOOID_IMAGE ??
"gitea.coracle.social/coracle/zooid:latest@sha256:7950843900cbb1c7c1f17de5524f6999badd1002cde94476855e90e67e0f2248" "gitea.coracle.social/coracle/zooid:latest@sha256:7950843900cbb1c7c1f17de5524f6999badd1002cde94476855e90e67e0f2248"
// How long after a recreate chromium can still abort what a page has in flight. Every netlink // How long after a recreate chromium can still abort what a page has in flight.
// event a create-and-destroy produces lands before `compose up --wait` returns — measured at 26 of
// them, all of them within a second, none after — but chromium coalesces interface changes for up
// to two seconds before acting on one, so the abort arrives well after the harness has moved on.
const SETTLE = 2500 const SETTLE = 2500
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
const configSource = fileURLToPath(new URL("docker/config", 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 // zooid saves a relay's toml back on a nip-86 edit, so it is handed a copy rather than the repo's.
// edits that relay's name, description or icon. A read-only mount fails those calls, and mounting
// the repo's own directory would leave one test rewriting the fixtures the rest read, so it is
// handed a copy. The image is distroless, so the copy is staged here rather than in an entrypoint.
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config") const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is // execFile does not go through a shell, so a docker that exists only as an alias needs E2E_DOCKER.
// invisible to it however well it works when typed. Name the executable to drive with E2E_DOCKER in
// that case.
const dockerCommand = process.env.E2E_DOCKER ?? "docker" const dockerCommand = process.env.E2E_DOCKER ?? "docker"
// Whether something already answers on the port the container publishes, asked before this run // Whether something already answers on the port the container publishes.
// brings its own up.
const isRelayPortTaken = () => const isRelayPortTaken = () =>
new Promise<boolean>(resolve => { new Promise<boolean>(resolve => {
const socket = createConnection({port, host: "127.0.0.1"}) const socket = createConnection({port, host: "127.0.0.1"})
@ -69,9 +57,7 @@ const isRelayPortTaken = () =>
socket.once("error", () => resolve(false)) socket.once("error", () => resolve(false))
}) })
// Every invocation carries the config directory and the image, `down` included. The compose file // The compose file names both without a default, so a call that left one out fails to interpolate.
// names both without a default, so a call that left one out fails to interpolate rather than
// quietly mounting something else or running a relay nothing here chose.
const docker = (...args: string[]) => const docker = (...args: string[]) =>
execFileAsync(dockerCommand, args, { execFileAsync(dockerCommand, args, {
env: {...process.env, ZOOID_CONFIG: configDir, ZOOID_IMAGE: image}, env: {...process.env, ZOOID_CONFIG: configDir, ZOOID_IMAGE: image},
@ -79,9 +65,7 @@ const docker = (...args: string[]) =>
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args) const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
// Why the container cannot be driven, or undefined when it can. A cli that is not on this process's // Why the container cannot be driven, or undefined when it can.
// PATH and a daemon that is not running need different answers, so they are reported apart rather
// than as one boolean.
const probeDocker = async () => { const probeDocker = async () => {
try { try {
await docker("compose", "version") await docker("compose", "version")
@ -105,9 +89,7 @@ const probeDocker = async () => {
} }
} }
// How far the container's clock is from this host's, in seconds, read off the Date header of the // How far the container's clock is from this host's, in seconds. Positive means the container is behind.
// relay's own http answer. Positive means the container is behind, so an event the harness has just
// signed looks like it comes from the future.
const getClockDrift = async (host: string) => { const getClockDrift = async (host: string) => {
const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"}) const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"})
@ -116,9 +98,7 @@ const getClockDrift = async (host: string) => {
} }
} }
// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and under a // nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go).
// container runtime's vm the two clocks belong to different machines. The relay's own one-line
// detail says nothing about how to fix that.
const describeClockDrift = (drift: number) => { const describeClockDrift = (drift: number) => {
const [minutes, direction] = const [minutes, direction] =
drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"] drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"]
@ -151,8 +131,7 @@ const getTestUser = (pubkey: string) => {
let problem: Maybe<Promise<Maybe<string>>> let problem: Maybe<Promise<Maybe<string>>>
// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the // Also written to the terminal, since a skip reason otherwise reaches only the html report.
// terminal, because a skip reason otherwise reaches only the html report.
export const describeDockerProblem = () => export const describeDockerProblem = () =>
(problem ??= probeDocker().then(reason => { (problem ??= probeDocker().then(reason => {
if (reason) { if (reason) {
@ -164,14 +143,9 @@ export const describeDockerProblem = () =>
return reason return reason
})) }))
/** /** The relay every test runs against. One container serves a virtual relay per entry in `tenants`. */
* The relay every test runs against. One zooid container on loopback serves a virtual relay per
* entry in `tenants`, and each relay's policy is its toml in docker/config, the only place policy
* is written down. A scenario describes what is on a relay, never what the relay is.
*/
export class Zooid { export class Zooid {
// Every socket into the container, the client's and seeding's alike, is one this process opened, // Every socket into the container is one this process opened, so this map also defines a url that is not a leak.
// so this map is also the definition of a url that is not a leak.
relays = new Map( relays = new Map(
tenantNames.map(name => [ tenantNames.map(name => [
tenantUrl(name), tenantUrl(name),
@ -189,8 +163,7 @@ export class Zooid {
// When the last recreate's interface churn stops being able to abort a request. // When the last recreate's interface churn stops being able to abort a request.
private settledAt = 0 private settledAt = 0
// Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are // Verifies docker rather than bringing the container up, which reset does. Repeat calls are free.
// free, so the fixture can call this per test.
start = async () => { start = async () => {
if (this.started) { if (this.started) {
return return
@ -246,11 +219,7 @@ export class Zooid {
} }
} }
// Leaves a fresh container for whoever runs next. Recreating one churns the host's network // Recreating churns the host's network interfaces, and chromium aborts what it has in flight.
// interfaces — a veth pair torn down and rebuilt, the project's bridge losing carrier with it —
// and chromium aborts every request it has in flight when it acts on that, which reaches the app
// as a route chunk that failed to import and, with `ssr = false`, a 500 page. So this is called
// from teardown rather than setup, and everything after it counts towards `settle` below.
reset = async () => { reset = async () => {
this.closeSessions() this.closeSessions()
@ -267,9 +236,7 @@ export class Zooid {
} }
} }
// Waited out before a page is opened, which is as late as it can be left: teardown, the next // As late as the wait can be left: teardown, the next test's fixtures and its seeding are inside it.
// test's fixtures and its seeding are all inside the window already, so most tests pay nothing
// here.
settle = async () => { settle = async () => {
const remaining = this.settledAt - Date.now() const remaining = this.settledAt - Date.now()
@ -289,8 +256,7 @@ export class Zooid {
} }
} }
// Fetching unasked is safe for a pinned reference: the pull can only produce the relay this suite // A pull of a pinned reference can only produce the relay this suite was written against.
// was written against, and a machine that already has it never gets here.
private fetchImage = async () => { private fetchImage = async () => {
console.warn(`\nFetching the zooid image this suite is pinned to:\n ${image}\n`) console.warn(`\nFetching the zooid image this suite is pinned to:\n ${image}\n`)
@ -308,8 +274,7 @@ export class Zooid {
} }
} }
// Whatever the relay wrote before it died. Compose reports only that a container exited, so // Compose reports only that a container exited, so without this a startup failure has no reason.
// without this a startup failure is a status code and no reason.
private logs = () => private logs = () =>
compose("logs", "--no-color", "--tail", "50").then( compose("logs", "--no-color", "--tail", "50").then(
({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(), ({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(),
@ -322,13 +287,9 @@ export class Zooid {
throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary) throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary)
} }
// Storage is tmpfs with no volume mounted for it, so a new container is a new database and // Storage is tmpfs with no volume mounted for it, so a new container is a new database.
// recreating is what makes this a reset.
private up = async () => { private up = async () => {
// A fresh copy on every recreate is what restores relay metadata a test edited through nip-86. // A fresh copy restores metadata a test edited through nip-86, and the container runs as uid 65532.
// 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 leaves zooid unable to write
// the file it was told to save.
await rm(configDir, {recursive: true, force: true}) await rm(configDir, {recursive: true, force: true})
await cp(configSource, configDir, {recursive: true}) await cp(configSource, configDir, {recursive: true})
await chmod(configDir, 0o777) await chmod(configDir, 0o777)
@ -348,8 +309,7 @@ export class Zooid {
const deadline = Date.now() + ms(30) const deadline = Date.now() + ms(30)
while (Date.now() < deadline) { while (Date.now() < deadline) {
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still loading.
// loading. Anything a relay itself answers means every tenant is ready.
const isUp = await Promise.all( const isUp = await Promise.all(
tenantNames.map(name => tenantNames.map(name =>
requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then( requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then(
@ -378,9 +338,7 @@ export class Zooid {
) )
} }
// NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The // NIP-42 binds an identity to a connection, and khatru rebuilds the signed url from transport.ts's headers.
// url signed into the auth event is the one the client uses, since that is what khatru rebuilds
// from the headers transport.ts sends.
private authenticate = async (host: string, user: TestUser) => { private authenticate = async (host: string, user: TestUser) => {
const key = `${host} ${user.pubkey}` const key = `${host} ${user.pubkey}`
const session = this.sessions.get(key) const session = this.sessions.get(key)
@ -403,8 +361,7 @@ export class Zooid {
const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}` const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}`
const drift = await getClockDrift(host).catch(() => undefined) const drift = await getClockDrift(host).catch(() => undefined)
// Nearly always the clock rather than the key. Every identity here is one zooid's toml names, // Nearly always the clock: the signature is checked after the timestamp the relay compares against.
// and the signature is checked after the timestamp the relay compares against its own.
if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) {
throw new Error(`${summary}\n\n${describeClockDrift(drift)}`) throw new Error(`${summary}\n\n${describeClockDrift(drift)}`)
} }
@ -412,8 +369,7 @@ export class Zooid {
throw new Error(summary) throw new Error(summary)
} }
// Both halves of seeding, the auth that opens a connection and every event written over it, are // Both the auth that opens a connection and every event written over it are answered with an OK.
// answered with an OK naming the event that was sent.
private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => { private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => {
connection.send(message) connection.send(message)

View file

@ -17,11 +17,7 @@ export type TestRelayOptions = {
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void> publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
} }
// Seeding signs the events a real client would send and publishes them over a socket. Room // admin is the only test identity the tomls grant can_manage, and every timestamp is the caller's.
// administration is signed by `admin`, the only test identity the tomls grant can_manage.
//
// Every timestamp is the caller's. Reading the wall clock here would stamp two fixtures that
// describe the same thing seconds apart, which is enough to decide which of them wins.
export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => { export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => {
const event = async (user: TestUser, template: StampedEvent) => { const event = async (user: TestUser, template: StampedEvent) => {
const signed = await user.signer.sign(template) const signed = await user.signer.sign(template)
@ -37,8 +33,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay
event, event,
publish, publish,
room: async (h, meta, createdAt) => { room: async (h, meta, createdAt) => {
// A relay stamps the metadata it derives from these ops with the op's own created_at, so // A relay stamps derived metadata with the op's created_at, so creation must be older than an edit.
// creation has to be strictly older than the edit or the edit is dropped as stale.
await event( await event(
users.admin, users.admin,
makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}), makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}),

View file

@ -4,21 +4,7 @@ import {parseJson} from "@welshman/lib"
import type {ClientMessage, RelayMessage} from "@welshman/net" import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {RelayConnection} from "./types" import type {RelayConnection} from "./types"
/** /** The only place that knows the container has a loopback address. A relay selection drops a local url. */
* How the harness reaches the zooid container, and the only place that knows it has a loopback
* address at all.
*
* The client is given `wss://<tenant>.test/` and never learns anything else. A relay selection
* drops a url that is local or insecure unless the caller opts in, which Flotilla never does
* (isLocalUrl and RelaySelection.getUrls in @welshman/util), so the container's own
* `ws://localhost:3334/` would leave the Router resolving every outbox load, profile and relay hint
* to nothing.
*
* The two headers below make the container answer to that name, and are what a tls terminator adds
* in front of a real deployment. zooid's dispatcher binds a config to a Host (cmd/relay/main.go),
* and khatru derives the url it checks nip-42 and nip-86 signatures against from that Host plus the
* forwarded proto (getBaseURL in khatru/relay.go).
*/
// Must match the published port in zooid/docker/compose.yaml. // Must match the published port in zooid/docker/compose.yaml.
export const port = 3334 export const port = 3334
@ -26,13 +12,11 @@ export const port = 3334
const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"})
export type ZooidConnection = RelayConnection & { export type ZooidConnection = RelayConnection & {
// Resolves with the first message the relay sends that matches. Seeding waits for its auth // Resolves with the first message the relay sends that matches.
// challenge and for the OK answering each write this way.
wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage> wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage>
} }
// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry // Node's own WebSocket cannot carry a Host header, which fetch forbids, so this depends on `ws`.
// a Host header, which fetch forbids, so the harness depends on `ws` for this alone.
export const connectToZooid = (host: string): ZooidConnection => { export const connectToZooid = (host: string): ZooidConnection => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)})
const listeners = new Set<(message: RelayMessage) => void>() const listeners = new Set<(message: RelayMessage) => void>()
@ -52,8 +36,7 @@ export const connectToZooid = (host: string): ZooidConnection => {
} }
}) })
// The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts // A 404 from zooid's dispatcher means `tenants` and the tenant toml's `host` have drifted apart.
// and the `host` in the tenant's toml have drifted apart.
socket.on("error", error => socket.on("error", error =>
console.error(`The zooid container refused a socket for ${host}: ${error.message}`), console.error(`The zooid container refused a socket for ${host}: ${error.message}`),
) )
@ -62,8 +45,7 @@ export const connectToZooid = (host: string): ZooidConnection => {
onMessage: listener => { onMessage: listener => {
listeners.add(listener) listeners.add(listener)
}, },
// A client may speak before the container has finished its handshake. The page's socket is open // A client may speak before the container has finished its handshake.
// as soon as playwright has a route for it, well before this one is.
send: message => { send: message => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
write(message) write(message)
@ -92,8 +74,7 @@ export type ZooidResponse = {
body: Buffer body: Buffer
} }
// One http request to the container, carrying the same two headers, so the nip-11 document and the // Carries the same two headers, so the nip-11 and nip-86 answers the browser reads are the relay's.
// nip-86 management api the browser reads are the real relay's.
export const requestZooid = ( export const requestZooid = (
host: string, host: string,
method: string, method: string,
@ -109,8 +90,7 @@ export const requestZooid = (
host: "127.0.0.1", host: "127.0.0.1",
port, port,
headers: { headers: {
// A pseudo-header describes the request line rather than the request, and node:http // A pseudo-header describes the request line, and node:http rejects a header name with a colon in it.
// rejects a header name with a colon in it.
...Object.fromEntries( ...Object.fromEntries(
Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")),
), ),
@ -122,8 +102,7 @@ export const requestZooid = (
const responseHeaders: Record<string, string> = {} const responseHeaders: Record<string, string> = {}
for (const [name, value] of Object.entries(response.headers)) { for (const [name, value] of Object.entries(response.headers)) {
// Hop-by-hop headers describe the connection this answer arrived on, not the one the // Hop-by-hop headers describe the connection this answer arrived on, not the browser's.
// browser is waiting on, which playwright frames itself.
if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) {
responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value
} }

View file

@ -3,9 +3,7 @@ import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {TestUser} from "../keys" import type {TestUser} from "../keys"
export type PublishOptions = { export type PublishOptions = {
// Which identity the seeding connection authenticates as. Defaults to the event's own author, // Which identity the seeding connection authenticates as, defaulting to the event's own author.
// which then has to be a test identity. A gift wrap is signed by an ephemeral key nothing in this
// process can authenticate as, so its connection belongs to the sender instead.
as?: TestUser as?: TestUser
} }
@ -17,9 +15,7 @@ export type RoomOptions = {
private?: boolean private?: boolean
} }
// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call // Every seeding call publishes over a real socket, so the relay stores what it would for a client.
// publishes over a real socket rather than inserting into storage, so the relay stores exactly what
// it would have stored for a real client.
export type TestRelay = { export type TestRelay = {
readonly name: string readonly name: string
readonly url: string readonly url: string
@ -29,13 +25,11 @@ export type TestRelay = {
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void> member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
event(user: TestUser, event: StampedEvent): Promise<SignedEvent> 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 // An event this process did not sign, sent over `as`'s connection, which a gift wrap needs.
// the ephemeral key that wrapped it, is the case that needs this.
publish(event: SignedEvent, options: PublishOptions): Promise<void> publish(event: SignedEvent, options: PublishOptions): Promise<void>
} }
// One client's connection to a relay. Every connection to a url reaches the same container, so one // Every connection to a url reaches the same container, so one context observes another's writes.
// browser context observes another's writes over the wire.
export type RelayConnection = { export type RelayConnection = {
onMessage(listener: (message: RelayMessage) => void): void onMessage(listener: (message: RelayMessage) => void): void
send(message: ClientMessage): void send(message: ClientMessage): void

View file

@ -19,9 +19,7 @@ import {
const ICON = gifFile("icon.gif") const ICON = gifFile("icon.gif")
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid // `free` is what RelayForm starts on. PricingTable names a plan by its member limit.
// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the
// only part of a card that differs between the two.
const PLANS = [ const PLANS = [
{id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false}, {id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false},
{ {
@ -29,7 +27,7 @@ const PLANS = [
name: "Basic", name: "Basic",
amount: 500, amount: 500,
hidden: false, hidden: false,
// Plan["members"] is number | null upstream; null is how the hosting API spells "unlimited". // Plan["members"] is number | null upstream, and null is how the hosting API spells unlimited.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
members: null, members: null,
blossom: true, blossom: true,
@ -37,8 +35,7 @@ const PLANS = [
}, },
] ]
// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it // Every field RelayDetailCard reads is present, since it calls .trim() and .replace() on several.
// calls .trim() and .replace() on several of them.
const hostedRelay = (overrides: Record<string, unknown> = {}) => ({ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
id: "relay-1", id: "relay-1",
tenant_pubkey: users.alice.pubkey, tenant_pubkey: users.alice.pubkey,
@ -65,26 +62,18 @@ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
...overrides, ...overrides,
}) })
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page // Tippy leaves a menu in the dom when it hides. Exact, since "Edit role" and "Edit roles" differ.
// that has opened two of them holds both — only the one on screen is visible. Exact, because
// "Edit role" and "Edit roles" are two different menus in the same directory.
const menuItem = (page: Page, name: string) => const menuItem = (page: Page, name: string) =>
page.getByRole("button", {name, exact: true}).filter({visible: true}) page.getByRole("button", {name, exact: true}).filter({visible: true})
// Every space-level admin action hangs off the space menu, which opens from the header button in // That button is labeled with the space's name and its host, and only the host survives a rename.
// the secondary nav. That button is labeled with the space's name and its host, and only the host
// survives a rename.
const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click() const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click()
// SpaceMember covers its card with a button whose aria-label is baked from the profile display at // The card's aria-label is baked at first render, so the card is found by the name it shows.
// first render — before the profile has loaded — so the card is found by the name it shows rather
// than by that label. It is the only interactive card in the directory, and the member's own menu
// is the last button inside it.
const memberCard = (page: Page, name: string) => const memberCard = (page: Page, name: string) =>
page.locator(".card-interactive").filter({hasText: name}) page.locator(".card-interactive").filter({hasText: name})
// Exact, because several permission names are a substring of another — "Ban members" of "Unban // Exact, because several permission names are a substring of another: "Ban members" of "Unban members".
// members".
const permission = (scope: Locator, name: string) => const permission = (scope: Locator, name: string) =>
scope.getByRole("checkbox", {name, exact: true}) scope.getByRole("checkbox", {name, exact: true})
@ -93,16 +82,12 @@ const openEventMenu = (card: Locator) => menuButton(card).click()
const articleCard = (page: Page, title: string) => const articleCard = (page: Page, title: string) =>
page.locator('[data-component="ArticleItem"]').filter({hasText: title}) page.locator('[data-component="ArticleItem"]').filter({hasText: title})
// A FieldInline puts its control in the div immediately after its label, which is how one row of // A FieldInline puts its control in the div immediately after its label.
// the hosting card is told apart from the others in the same grid.
const setting = (page: Page, label: string) => const setting = (page: Page, label: string) =>
page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div") page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div")
const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox") const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox")
// A hosted relay's status and its plan each render as a badge beside its name.
const badge = (page: Page, text: string) => page.locator(".badge").filter({hasText: text})
test("US-092 edit a space's profile and featured content", async ({seed, as}) => { test("US-092 edit a space's profile and featured content", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
@ -122,12 +107,9 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
const editor = dialog(admin, "Edit this Space") const editor = dialog(admin, "Edit this Space")
await expect(editor).toBeVisible()
await editor.getByRole("button", {name: "Add an image"}).click() await editor.getByRole("button", {name: "Add an image"}).click()
// The upload's file input is hidden inside its own label, so the chooser is opened by clicking // The upload's file input is hidden inside its own label, so the chooser opens by clicking it.
// the label rather than by writing to the input. Picking a file dismisses the picker.
const picker = dialog(admin, "Add an image") const picker = dialog(admin, "Add an image")
const chooser = admin.waitForEvent("filechooser") const chooser = admin.waitForEvent("filechooser")
@ -151,8 +133,6 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
.locator(".card") .locator(".card")
.filter({has: admin.getByRole("heading", {name: "Featured"})}) .filter({has: admin.getByRole("heading", {name: "Featured"})})
await expect(featured.getByText("No featured content yet.")).toBeVisible()
await featured.getByRole("button").first().click() await featured.getByRole("button").first().click()
const featuredEditor = dialog(admin, "Featured Content") const featuredEditor = dialog(admin, "Featured Content")
@ -164,7 +144,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
await expect(admin.getByRole("alert")).toContainText("Featured content updated!") await expect(admin.getByRole("alert")).toContainText("Featured content updated!")
await expect(featured.getByText("Read the harbor rules first")).toBeVisible() await expect(featured.getByText("Read the harbor rules first")).toBeVisible()
// What admin published reaches every visitor, without any of the controls that produced it // What admin published reaches every visitor
const bob = await as(users.bob, spacePath(url) + "/about") const bob = await as(users.bob, spacePath(url) + "/about")
await expect(bob.getByRole("heading", {name: "Harbor"})).toBeVisible() await expect(bob.getByRole("heading", {name: "Harbor"})).toBeVisible()
@ -174,12 +154,6 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
.filter({has: bob.getByRole("heading", {name: "Featured"})}) .filter({has: bob.getByRole("heading", {name: "Featured"})})
await expect(bobsFeatured.getByText("Read the harbor rules first")).toBeVisible() await expect(bobsFeatured.getByText("Read the harbor rules first")).toBeVisible()
await expect(bobsFeatured.getByRole("button")).toHaveCount(0)
await openSpaceMenu(bob)
await expect(menuItem(bob, "Create Invite")).toBeVisible()
await expect(bob.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0)
}) })
test("US-093 create roles and assign them", async ({seed, as}) => { test("US-093 create roles and assign them", async ({seed, as}) => {
@ -203,9 +177,6 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
await menuItem(admin, "Manage Roles").click() await menuItem(admin, "Manage Roles").click()
const roles = dialog(admin, "Manage Roles") const roles = dialog(admin, "Manage Roles")
const emptyState = roles.getByText("No roles yet. Create one to start organizing members.")
await expect(emptyState).toBeVisible()
await roles.getByRole("button", {name: "Create Role"}).click() await roles.getByRole("button", {name: "Create Role"}).click()
@ -245,12 +216,9 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
await roles.getByRole("button", {name: "Go back"}).click() await roles.getByRole("button", {name: "Go back"}).click()
const bobsBadge = memberCard(admin, "Bob Barnacle").locator(".role-badge") await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveText("Bosun")
await expect(bobsBadge).toHaveText("Bosun") // ...or from a member's own card
await expect(bobsBadge).toHaveAttribute("style", /--role-color: hsl\(200/)
// ...or from a member's own card, which is also how it comes back off
await memberCard(admin, "Carol Cutter").getByRole("button").last().click() await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
await menuItem(admin, "Edit roles").click() await menuItem(admin, "Edit roles").click()
@ -262,51 +230,19 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
await expect(admin.getByRole("alert")).toContainText("Roles updated!") await expect(admin.getByRole("alert")).toContainText("Roles updated!")
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun") await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun")
// The directory's search covers the roles a member holds as well as their name
const term = admin.getByPlaceholder("Search people or roles...")
await term.fill("Bosun")
await expect(admin.locator(".card-interactive")).toHaveCount(2)
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
await term.fill("")
await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
await menuItem(admin, "Edit roles").click()
await carolsRoles.getByRole("checkbox").uncheck()
await carolsRoles.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByRole("alert")).toContainText("Roles updated!")
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0)
await admin.getByRole("button", {name: "More options"}).click() await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Manage Roles").click() await menuItem(admin, "Manage Roles").click()
await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click() await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click()
await menuItem(admin, "Delete role").click() await menuItem(admin, "Delete role").click()
const confirm = dialog(admin, "Delete Role") await dialog(admin, "Delete Role").getByRole("button", {name: "Confirm"}).click()
await expect(
confirm.getByText('Delete the "Bosun" role? Members will keep their space membership.'),
).toBeVisible()
await confirm.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Role deleted!") await expect(admin.getByRole("alert")).toContainText("Role deleted!")
await expect(emptyState).toBeVisible()
await roles.getByRole("button", {name: "Go back"}).click() await roles.getByRole("button", {name: "Go back"}).click()
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0) await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0)
const bob = await as(users.bob, spacePath(url) + "/directory")
await expect(memberCard(bob, "Carol Cutter")).toBeVisible()
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
}) })
test("US-094 invite people to a space", async ({seed, as}) => { test("US-094 invite people to a space", async ({seed, as}) => {
@ -321,8 +257,7 @@ test("US-094 invite people to a space", async ({seed, as}) => {
space.join(user.bob, "general") space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"}) space.profile(user.bob, {name: "Bob Barnacle"})
// Somebody who belongs to a different space, so adding her here is a change to this space's // Somebody who belongs to a different space, so adding her here is a change to this one.
// member list rather than something the scenario already seeded.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.admin, "lounge") other.join(user.admin, "lounge")
other.join(newcomer, "lounge") other.join(newcomer, "lounge")
@ -332,11 +267,8 @@ test("US-094 invite people to a space", async ({seed, as}) => {
const other = scenario.space("other") const other = scenario.space("other")
// Arriving through the other space is what loads Nadia's profile: the invite dialog's search // The invite dialog's search reads the profiles this client already holds.
// reads the profiles this client already holds. const page = await as(users.admin, roomPath(other.url, "lounge"))
const page = await as(users.admin, roomPath(other.url, "lounge"), {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
await expect(page.getByText("Nadia Newcomer").first()).toBeVisible() await expect(page.getByText("Nadia Newcomer").first()).toBeVisible()
@ -346,22 +278,21 @@ test("US-094 invite people to a space", async ({seed, as}) => {
await menuItem(page, "Create Invite").click() await menuItem(page, "Create Invite").click()
const invite = dialog(page, "Create an Invite") const invite = dialog(page, "Create an Invite")
const link = invite.locator("input[readonly]")
await expect(invite.locator("canvas")).toBeVisible() await expect(invite.locator("input[readonly]")).toHaveValue(/\/join\?r=space\.test&c=.+/)
await expect(link).toHaveValue(/\/join\?r=space\.test&c=.+/)
await invite.locator("label:has(input[readonly])").getByRole("button").click() await invite.getByRole("button", {name: "Add members"}).click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") const addMembers = dialog(page, "Add Members")
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await link.inputValue())
await invite.getByPlaceholder("Search for profiles...").fill("Nadia") await addMembers.getByPlaceholder("Search for profiles...").fill("Nadia")
await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click() await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click()
await invite.getByRole("button", {name: "Save", exact: true}).click() await addMembers.getByRole("button", {name: "Save", exact: true}).click()
await expect(page.getByRole("alert")).toContainText("Members have successfully been added!") await expect(page.getByRole("alert")).toContainText("Members have successfully been added!")
// Adding members opened over the invite, so saving goes back to it.
await invite.getByRole("button", {name: "Done"}).click()
await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click() await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click()
await expect(memberCard(page, "Nadia Newcomer")).toBeVisible() await expect(memberCard(page, "Nadia Newcomer")).toBeVisible()
@ -371,10 +302,7 @@ test("US-094 invite people to a space", async ({seed, as}) => {
await page.locator('[data-component="PageBar"]').getByRole("button").last().click() await page.locator('[data-component="PageBar"]').getByRole("button").last().click()
await page.getByRole("button", {name: "Create invite"}).click() await page.getByRole("button", {name: "Create invite"}).click()
const roomInvite = dialog(page, "Create a Room Invite") await expect(dialog(page, "Create a Room Invite").locator("input[readonly]")).toHaveValue(
await expect(roomInvite.locator("canvas")).toBeVisible()
await expect(roomInvite.locator("input[readonly]")).toHaveValue(
/\/join\?r=space\.test&c=[^&]+&h=general&code=.+/, /\/join\?r=space\.test&c=[^&]+&h=general&code=.+/,
) )
}) })
@ -392,25 +320,12 @@ test("US-095 remove, ban, and restore members", async ({seed, as}) => {
}) })
const {url} = scenario.space("space") const {url} = scenario.space("space")
// An ordinary member reads the same directory without any of the controls that manage it
const bob = await as(users.bob, spacePath(url) + "/directory")
const carolsCard = memberCard(bob, "Carol Cutter")
await expect(carolsCard).toBeVisible()
await expect(carolsCard.getByRole("button")).toHaveCount(1)
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
const admin = await as(users.admin, spacePath(url) + "/directory") const admin = await as(users.admin, spacePath(url) + "/directory")
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click() await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
await menuItem(admin, "Remove member").click() await menuItem(admin, "Remove member").click()
const removal = dialog(admin, "Remove Member") await dialog(admin, "Remove Member").getByRole("button", {name: "Confirm"}).click()
await expect(removal.getByText("Remove @Bob Barnacle from the space?")).toBeVisible()
await removal.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!") await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!")
await expect(memberCard(admin, "Bob Barnacle")).toHaveCount(0) await expect(memberCard(admin, "Bob Barnacle")).toHaveCount(0)
@ -418,11 +333,7 @@ test("US-095 remove, ban, and restore members", async ({seed, as}) => {
await memberCard(admin, "Carol Cutter").getByRole("button").last().click() await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
await menuItem(admin, "Ban member").click() await menuItem(admin, "Ban member").click()
const ban = dialog(admin, "Ban Member") await dialog(admin, "Ban Member").getByRole("button", {name: "Confirm"}).click()
await expect(ban.getByText("Ban @Carol Cutter from the space?")).toBeVisible()
await ban.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!") await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!")
await expect(memberCard(admin, "Carol Cutter")).toHaveCount(0) await expect(memberCard(admin, "Carol Cutter")).toHaveCount(0)
@ -432,8 +343,6 @@ test("US-095 remove, ban, and restore members", async ({seed, as}) => {
const banned = dialog(admin, "Banned users") const banned = dialog(admin, "Banned users")
await expect(banned.getByText("Carol Cutter")).toBeVisible()
await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click() await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click()
await menuItem(admin, "Restore User").click() await menuItem(admin, "Restore User").click()
@ -491,38 +400,21 @@ test("US-096 moderate messages and posts", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
// An author gets a delete on their own content, and nothing to report // An author gets a delete on their own content
const alice = await as(users.alice, roomPath(url, "general")) const alice = await as(users.alice, roomPath(url, "general"))
await expect(alice.getByText("the hull is patched")).toBeVisible()
await openMessageMenu(alice, "the hull is patched") await openMessageMenu(alice, "the hull is patched")
await expect(menuItem(alice, "Delete Message")).toBeVisible() await expect(menuItem(alice, "Delete Message")).toBeVisible()
await expect(alice.getByRole("button", {name: "Report Content", exact: true})).toHaveCount(0)
await alice.goto(spacePath(url) + "/articles") // Everybody else gets a report instead
await openEventMenu(articleCard(alice, "Tides and Timetables"))
await expect(menuItem(alice, "Delete Article")).toBeVisible()
// Everybody else gets a report instead, and the report needs a reason
const bob = await as(users.bob, roomPath(url, "general")) const bob = await as(users.bob, roomPath(url, "general"))
await openMessageMenu(bob, "the hull is patched") await openMessageMenu(bob, "the hull is patched")
await expect(menuItem(bob, "Report Content")).toBeVisible()
await expect(bob.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0)
await menuItem(bob, "Report Content").click() await menuItem(bob, "Report Content").click()
const report = dialog(bob, "Report Content") const report = dialog(bob, "Report Content")
await report.getByRole("button", {name: "Send Report"}).click()
await expect(bob.getByRole("alert")).toContainText("Please select a reason for your report.")
await expect(report).toBeVisible()
await report.getByRole("combobox").selectOption("Spam") await report.getByRole("combobox").selectOption("Spam")
await report.getByRole("button", {name: "Send Report"}).click() await report.getByRole("button", {name: "Send Report"}).click()
@ -536,13 +428,7 @@ test("US-096 moderate messages and posts", async ({seed, as}) => {
await openMessageMenu(admin, "aye captain") await openMessageMenu(admin, "aye captain")
await menuItem(admin, "Delete Message").click() await menuItem(admin, "Delete Message").click()
const messageConfirm = dialog(admin, "Delete Message") await dialog(admin, "Delete Message").getByRole("button", {name: "Confirm"}).click()
await expect(
messageConfirm.getByText("Are you sure you want to delete this message from the space?"),
).toBeVisible()
await messageConfirm.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!")
@ -550,13 +436,7 @@ test("US-096 moderate messages and posts", async ({seed, as}) => {
await openEventMenu(articleCard(admin, "Charting the Reef")) await openEventMenu(articleCard(admin, "Charting the Reef"))
await menuItem(admin, "Delete Article").click() await menuItem(admin, "Delete Article").click()
const articleConfirm = dialog(admin, "Delete Article") await dialog(admin, "Delete Article").getByRole("button", {name: "Confirm"}).click()
await expect(
articleConfirm.getByText("Are you sure you want to delete this article from the space?"),
).toBeVisible()
await articleConfirm.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!")
@ -577,8 +457,7 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
// A room the relay will not admit anyone to on their own say-so, so a join request stays // A room the relay will not admit anyone to on their own say-so, so a join request stays pending.
// pending instead of being granted the moment it lands.
space.room("vault", {name: "Vault", closed: true, private: true}) space.room("vault", {name: "Vault", closed: true, private: true})
space.join(user.admin, "general", "vault") space.join(user.admin, "general", "vault")
space.join(user.alice, "general") space.join(user.alice, "general")
@ -593,14 +472,6 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const admin = await as(users.admin, spacePath(url) + "/about") const admin = await as(users.admin, spacePath(url) + "/about")
const queueButton = admin.getByRole("button", {name: /^Action Items/})
await openSpaceMenu(admin)
await expect(queueButton).toHaveText("Action Items (0)")
await expect(queueButton.locator(".bg-primary")).toHaveCount(0)
await admin.keyboard.press("Escape")
const report = async (page: Page, text: string, reason: string) => { const report = async (page: Page, text: string, reason: string) => {
await openMessageMenu(page, text) await openMessageMenu(page, text)
@ -636,11 +507,7 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
await askToJoinVault(carol) await askToJoinVault(carol)
await openSpaceMenu(admin) await openSpaceMenu(admin)
await admin.getByRole("button", {name: /^Action Items/}).click()
await expect(queueButton).toHaveText("Action Items (4)")
await expect(queueButton.locator(".bg-primary")).toHaveCount(1)
await queueButton.click()
const queue = dialog(admin, "Action Items") const queue = dialog(admin, "Action Items")
const item = (text: string, kind: string) => const item = (text: string, kind: string) =>
@ -678,13 +545,7 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
await spamReport.getByRole("button").first().click() await spamReport.getByRole("button").first().click()
await menuItem(admin, "Remove Content").click() await menuItem(admin, "Remove Content").click()
const removal = dialog(admin, "Remove Content") await dialog(admin, "Remove Content").getByRole("button", {name: "Confirm"}).click()
await expect(
removal.getByText("Are you sure you want to delete this content from the space?"),
).toBeVisible()
await removal.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!") await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!")
await expect(spamReport).toHaveCount(0) await expect(spamReport).toHaveCount(0)
@ -710,14 +571,6 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
await expect(alice.getByText("the sails are torn")).toBeVisible() await expect(alice.getByText("the sails are torn")).toBeVisible()
await expect(alice.getByText("the hull is patched")).toHaveCount(0) await expect(alice.getByText("the hull is patched")).toHaveCount(0)
// A member never sees the queue at all
const bobsSpace = await as(users.bob, spacePath(url) + "/about")
await openSpaceMenu(bobsSpace)
await expect(menuItem(bobsSpace, "Create Invite")).toBeVisible()
await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0)
}) })
test("US-130 share out admin permissions", async ({seed, as}) => { test("US-130 share out admin permissions", async ({seed, as}) => {
@ -733,20 +586,6 @@ test("US-130 share out admin permissions", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const admin = await as(users.admin, spacePath(url) + "/directory") const admin = await as(users.admin, spacePath(url) + "/directory")
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Admins").click()
const admins = dialog(admin, "Admins")
// The owner holds every method implicitly, so the relay leaves them out of listmethodassignees
// and the client names them from the space's nip 11 pubkey instead.
await expect(admins.locator(".badge").filter({hasText: "Owner"})).toBeVisible()
await expect(admins.getByText("Nobody else has been given management permissions.")).toBeVisible()
await admins.getByRole("button", {name: "Go back"}).click()
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click() await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
await menuItem(admin, "Edit permissions").click() await menuItem(admin, "Edit permissions").click()
@ -761,6 +600,8 @@ test("US-130 share out admin permissions", async ({seed, as}) => {
await admin.getByRole("button", {name: "More options"}).click() await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Admins").click() await menuItem(admin, "Admins").click()
const admins = dialog(admin, "Admins")
await expect( await expect(
admins.locator(".card").filter({hasText: "Bob Barnacle"}).locator(".badge"), admins.locator(".card").filter({hasText: "Bob Barnacle"}).locator(".badge"),
).toHaveText(["Ban members", "List banned members"]) ).toHaveText(["Ban members", "List banned members"])
@ -802,14 +643,10 @@ test("US-098 browse and create hosted spaces", async ({seed, as}) => {
const page = await as(users.alice, "/settings/hosting", { const page = await as(users.alice, "/settings/hosting", {
hosting: {plans: PLANS}, hosting: {plans: PLANS},
// A space created here is opened at wss://<subdomain>.<domain>/, so the domain has to be one // A space created here is opened at wss://<subdomain>.<domain>/, which has to name a tenant.
// the container serves and the subdomain has to name one of its tenants.
env: {VITE_HOSTING_RELAY_DOMAIN: "test"}, env: {VITE_HOSTING_RELAY_DOMAIN: "test"},
}) })
await expect(page.getByText("You don't host any spaces yet.")).toBeVisible()
await expect(page.getByText("Create a space to get started.")).toBeVisible()
await page.getByRole("button", {name: "Create a Space"}).click() await page.getByRole("button", {name: "Create a Space"}).click()
const form = dialog(page, "New Space") const form = dialog(page, "New Space")
@ -829,17 +666,12 @@ test("US-098 browse and create hosted spaces", async ({seed, as}) => {
const listed = page.locator(".card").filter({hasText: "space.test"}).first() const listed = page.locator(".card").filter({hasText: "space.test"}).first()
await expect(listed).toContainText("Space")
await expect(listed).toContainText("Active") await expect(listed).toContainText("Active")
await expect(listed).toContainText("Free") await expect(listed).toContainText("Free")
await expect(listed.getByRole("link", {name: "Manage"})).toBeVisible()
// A paid plan has to be paid for, so creation hands straight over to payment // A paid plan has to be paid for, so creation hands straight over to payment
await page.getByRole("button", {name: "Create a Space"}).click() await page.getByRole("button", {name: "Create a Space"}).click()
await form.getByPlaceholder("My Space").fill("Other") await form.getByPlaceholder("My Space").fill("Other")
await expect(form.getByPlaceholder("my-space")).toHaveValue("other")
await form.getByRole("button").filter({hasText: "Unlimited members"}).click() await form.getByRole("button").filter({hasText: "Unlimited members"}).click()
await form.getByRole("button", {name: "Create Space", exact: true}).click() await form.getByRole("button", {name: "Create Space", exact: true}).click()
@ -901,25 +733,14 @@ test("US-099 configure a hosted relay", async ({seed, as}) => {
await expect(settingToggle(alice, "Public read")).toBeChecked() await expect(settingToggle(alice, "Public read")).toBeChecked()
// The free plan doesn't include either of these, and says so rather than failing on save // The free plan doesn't include either of these
const blossom = settingToggle(alice, "Media storage") await expect(settingToggle(alice, "Media storage")).toBeDisabled()
await expect(blossom).toBeDisabled()
await expect(settingToggle(alice, "LiveKit support")).toBeDisabled() await expect(settingToggle(alice, "LiveKit support")).toBeDisabled()
// The tooltip hangs off a wrapper rather than the input, so it still shows while it is disabled
await blossom.locator("xpath=..").hover()
await expect(alice.getByText("Not available on your current plan").first()).toBeVisible()
// A member of the space who doesn't own it has nothing to manage here // A member of the space who doesn't own it has nothing to manage here
const bob = await as(users.bob, spacePath(url) + "/admin") const bob = await as(users.bob, spacePath(url) + "/admin")
await expect(bob.getByRole("heading", {name: "Not a Coracle-hosted space"})).toBeVisible() await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toBeVisible()
await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toHaveAttribute(
"href",
"/settings/hosting",
)
}) })
test("US-100 change a hosted relay's plan", async ({seed, as}) => { test("US-100 change a hosted relay's plan", async ({seed, as}) => {
@ -952,15 +773,12 @@ test("US-100 change a hosted relay's plan", async ({seed, as}) => {
await page.getByRole("button", {name: "Set up later"}).click() await page.getByRole("button", {name: "Set up later"}).click()
await expect(badge(page, "Basic")).toBeVisible()
await expect(setting(page, "Current plan")).toHaveText("Basic") await expect(setting(page, "Current plan")).toHaveText("Basic")
// The paid plan unlocks both gated features // The paid plan unlocks both gated features
const blossom = settingToggle(page, "Media storage") const blossom = settingToggle(page, "Media storage")
const livekit = settingToggle(page, "LiveKit support") const livekit = settingToggle(page, "LiveKit support")
await expect(blossom).toBeEnabled()
await blossom.click() await blossom.click()
await livekit.click() await livekit.click()
@ -973,7 +791,6 @@ test("US-100 change a hosted relay's plan", async ({seed, as}) => {
await plans.getByRole("button", {name: "Save", exact: true}).click() await plans.getByRole("button", {name: "Save", exact: true}).click()
await expect(page.getByRole("alert")).toContainText("Plan updated.") await expect(page.getByRole("alert")).toContainText("Plan updated.")
await expect(badge(page, "Free")).toBeVisible()
await expect(setting(page, "Current plan")).toHaveText("Free") await expect(setting(page, "Current plan")).toHaveText("Free")
await expect(settingToggle(page, "Media storage")).not.toBeChecked() await expect(settingToggle(page, "Media storage")).not.toBeChecked()
await expect(settingToggle(page, "LiveKit support")).not.toBeChecked() await expect(settingToggle(page, "LiveKit support")).not.toBeChecked()
@ -987,8 +804,7 @@ test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => {
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// Where the space ends up once the domain verifies, so the client has somewhere real to follow // Where the space ends up once the domain verifies.
// it to.
other.room("general", {name: "General"}) other.room("general", {name: "General"})
other.join(user.alice, "general") other.join(user.alice, "general")
}) })
@ -996,11 +812,8 @@ test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.alice, spacePath(url) + "/admin", { const page = await as(users.alice, spacePath(url) + "/admin", {
hosting: {plans: PLANS, relays: [hostedRelay()]}, hosting: {plans: PLANS, relays: [hostedRelay()]},
context: {permissions: ["clipboard-read", "clipboard-write"]},
}) })
await expect(page.getByText("Not configured")).toBeVisible()
await page.getByRole("button", {name: "Add domain"}).click() await page.getByRole("button", {name: "Add domain"}).click()
const domainForm = dialog(page, "Custom domain") const domainForm = dialog(page, "Custom domain")
@ -1021,19 +834,6 @@ test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => {
await expect(page.getByText("other.test", {exact: true})).toBeVisible() await expect(page.getByText("other.test", {exact: true})).toBeVisible()
await expect(page.getByText("other.test ALIAS space.test")).toBeVisible() await expect(page.getByText("other.test ALIAS space.test")).toBeVisible()
await page.locator('[data-tip="Copy ALIAS target"]').click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("space.test")
// The backend hasn't seen the record yet, so verifying says so and leaves the badge alone
await page.getByRole("button", {name: "Verify DNS record"}).click()
await expect(page.getByRole("alert")).toContainText(
"Not verified yet. DNS changes can take a while to propagate.",
)
await expect(page.getByText("Pending", {exact: true})).toBeVisible()
getHosting(page.context()).setRelay("relay-1", {custom_domain_verified: 1}) getHosting(page.context()).setRelay("relay-1", {custom_domain_verified: 1})
await page.getByRole("button", {name: "Verify DNS record"}).click() await page.getByRole("button", {name: "Verify DNS record"}).click()
@ -1062,9 +862,7 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
hosting: { hosting: {
plans: PLANS, plans: PLANS,
relays: [hostedRelay({plan_id: "basic"})], relays: [hostedRelay({plan_id: "basic"})],
// Declared oldest first, which is not the order payment history reads in. Invoice's // Declared oldest first, which is not the order payment history reads in. null is "not yet".
// paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is
// how the hosting API spells "hasn't happened yet".
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
invoices: [ invoices: [
{ {
@ -1099,11 +897,7 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
await page.getByRole("button", {name: "Relay actions"}).click() await page.getByRole("button", {name: "Relay actions"}).click()
await menuItem(page, "Deactivate").click() await menuItem(page, "Deactivate").click()
const deactivate = dialog(page, "Deactivate relay?") await dialog(page, "Deactivate relay?").getByRole("button", {name: "Confirm"}).click()
await expect(deactivate.getByText(/All client connections are dropped/)).toBeVisible()
await deactivate.getByRole("button", {name: "Confirm"}).click()
await expect(page.getByText("Inactive", {exact: true})).toBeVisible() await expect(page.getByText("Inactive", {exact: true})).toBeVisible()
@ -1127,14 +921,6 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
const payment = dialog(page, "Pay invoice") const payment = dialog(page, "Pay invoice")
await expect(payment.getByText("$9.00")).toBeVisible() await expect(payment.getByText("$9.00")).toBeVisible()
await expect(payment.locator("canvas")).toBeVisible()
await expect(payment.locator("input[readonly]")).toHaveValue("lnbcinvoice-open")
await payment.getByRole("button", {name: "Check payment"}).click()
await expect(page.getByRole("alert")).toContainText(
"Payment not yet confirmed. Please try again after sending.",
)
getHosting(page.context()).setInvoice("invoice-open", {paid_at: now(), method: "nwc"}) getHosting(page.context()).setInvoice("invoice-open", {paid_at: now(), method: "nwc"})
@ -1168,20 +954,8 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
const history = page.locator(".card").filter({hasText: "Payment History"}).first() const history = page.locator(".card").filter({hasText: "Payment History"}).first()
const invoices = history.getByRole("listitem") const invoices = history.getByRole("listitem")
// The whole period, not just its start: the two invoices meet at a month boundary, so either
// date on its own reads the same on both of them. Formatted by the browser rather than by node,
// so the locale is the one the app rendered with — see dayLabel in dms.spec.ts.
const period = ({start, end}: {start: number; end: number}) =>
page.evaluate(
([from, to]) =>
`${new Date(from * 1000).toLocaleDateString()} – ${new Date(to * 1000).toLocaleDateString()}`,
[start, end],
)
await expect(invoices.first()).toContainText("$9.00") await expect(invoices.first()).toContainText("$9.00")
await expect(invoices.first()).toContainText(await period(openPeriod))
await expect(invoices.nth(1)).toContainText("$5.00") await expect(invoices.nth(1)).toContainText("$5.00")
await expect(invoices.nth(1)).toContainText(await period(paidPeriod))
}) })
test("US-122 export and import a hosted relay's data", async ({seed, as}) => { test("US-122 export and import a hosted relay's data", async ({seed, as}) => {
@ -1221,9 +995,7 @@ test("US-122 export and import a hosted relay's data", async ({seed, as}) => {
await expect(modal.getByText("line 2: invalid event")).toBeVisible() await expect(modal.getByText("line 2: invalid event")).toBeVisible()
}) })
// The space menu button is labeled with the space's host, the way `openSpaceMenu` relies on for // delegated.test grants every member one management method, so `supportedmethods` differs by user.
// space.test. delegated.test grants every member one management method, so `supportedmethods` comes
// back with a single entry for a member and the whole list for admin, who owns the relay.
const openDelegatedMenu = (page: Page) => const openDelegatedMenu = (page: Page) =>
page.getByRole("button", {name: /delegated\.test/}).click() page.getByRole("button", {name: /delegated\.test/}).click()

View file

@ -1,16 +1,13 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib" import {HOUR, MINUTE} from "@welshman/lib"
import {COMMENT, LONG_FORM, MESSAGE, NOTE, makeEvent, tagSpec, tagValue} from "@welshman/util" import {COMMENT, LONG_FORM, MESSAGE, tagSpec, tagValue} from "@welshman/util"
import type {SignedEvent} from "@welshman/util" import type {SignedEvent} from "@welshman/util"
import {Article, Comment, Thread} from "@welshman/domain" import {Article, Comment, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import { import {
DEFAULT_BLOSSOM_ORIGIN,
GIF,
emojiButton, emojiButton,
expect, expect,
getPublishedEvents, getPublishedEvents,
mockBlossom,
modalForm, modalForm,
noteEditor, noteEditor,
pageBar, pageBar,
@ -24,15 +21,11 @@ import {
// A handle to a seeded event, which only reads once seed() has drained its queue. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent} type Seeded = {readonly id: string; readonly event: SignedEvent}
const PARTY = "🎉" // The comment and thread-reply composers are the only forms on their pages with a rich text editor.
// The comment and thread-reply composers are the only forms on their pages carrying a rich text
// editor.
const composerForm = (page: Page) => const composerForm = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")}) page.locator("form").filter({has: page.locator(".note-editor")})
// RoomCompose's join is the upload button and then the compose menu, which is where an article or // A room composer's join is the upload button and then the compose menu.
// a thread written from inside a room is started.
const openComposeMenu = (page: Page) => const openComposeMenu = (page: Page) =>
page page
.locator("form") .locator("form")
@ -42,107 +35,57 @@ const openComposeMenu = (page: Page) =>
.nth(1) .nth(1)
.click() .click()
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper") // One reaction, added and taken back off again.
// One reaction, recorded as the reader's own, and taken back off again.
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => { const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
const pill = scope.getByRole("button", {name: new RegExp(PARTY)}) const pill = scope.getByRole("button", {name: /🎉/})
await pickParty(page, opener) await pickEmoji(page, opener, "party popper")
await expect(pill).toHaveCount(1) await expect(pill).toHaveCount(1)
await expect(pill).toHaveClass(/button-primary/)
await pill.click() await pill.click()
await expect(pill).toHaveCount(0) await expect(pill).toHaveCount(0)
} }
// formatTimestamp renders a short date and a short time, so the date half of it is what a spec can // The card is a div carrying an overlay link, so it is found by its component rather than a role.
// name without pinning a format. Formatted by the browser rather than by node, so the locale and the
// timezone are the ones the app rendered with — see dayLabel in dms.spec.ts.
const shortDate = (page: Page, seconds: number) =>
page.evaluate(
ts => new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(ts * 1000)),
seconds,
)
// 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 articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
// A comment is a flat block in the tree rather than a card, so it carries a component marker for // A comment is a flat block rather than a card, and the marker sits on its own row, not its replies.
// the specs to name; the marker sits on the comment's own row, not on its replies.
const comment = (page: Page, text: string) => const comment = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text}) page.locator('[data-component="Comment"]').filter({hasText: text})
// Clicked near its top-left corner rather than at its centre: the link is an overlay covering the // Clicked near its top-left: a card whose footer wraps puts an interactive row under the centre.
// whole card, and a card whose footer wraps onto a second line puts that interactive row under the
// centre point, where it swallows the click.
const openArticle = async (page: Page, title: string) => { const openArticle = async (page: Page, title: string) => {
await articleCards(page) await articleCards(page)
.filter({hasText: title}) .filter({hasText: title})
.getByRole("link", {name: title, exact: true}) .getByRole("link", {name: title, exact: true})
.click({position: {x: 20, y: 20}}) .click({position: {x: 20, y: 20}})
// The card's own action bar carries a data-component the article page carries too, and the list // The card's own action bar carries the same data-component, and the list is still on screen.
// is still on screen while the route loads, so a spec that names one straight after this click
// gets the card's. The article body is only on the page it navigated to.
await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible() await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible()
} }
test("US-037 write and publish an article", async ({seed, as}) => { test("US-037 write and publish an article", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"}) 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 {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/articles`) const page = await as(users.alice, `${spacePath(url)}/articles`)
await expect(articleCards(page)).toHaveCount(1)
await pageBar(page).getByRole("button", {name: "Write"}).click() await pageBar(page).getByRole("button", {name: "Write"}).click()
const publish = pageBar(page).getByRole("button", {name: "Publish"}) const publish = pageBar(page).getByRole("button", {name: "Publish"})
const title = page.getByPlaceholder("Title", {exact: true}) const title = page.getByPlaceholder("Title", {exact: true})
const body = noteEditor(page) const body = noteEditor(page)
await expect(title).toBeVisible()
await publish.click()
await expect(page.getByRole("alert")).toContainText("Please provide a title for your article.")
await title.fill("Half Baked") await title.fill("Half Baked")
await publish.click()
await expect(page.getByRole("alert")).toContainText("Please write something for your article.")
await body.pressSequentially("Only the beginning.") await body.pressSequentially("Only the beginning.")
// Neither refusal put anything on the wire.
expect(getPublishedEvents(page.context(), LONG_FORM)).toEqual([])
await page.goBack() await page.goBack()
await expect(articleCards(page)).toHaveCount(1)
// Reopening picks up where the composer was closed rather than starting over. // Reopening picks up where the composer was closed rather than starting over.
await pageBar(page).getByRole("button", {name: "Write"}).click() await pageBar(page).getByRole("button", {name: "Write"}).click()
@ -158,8 +101,7 @@ test("US-037 write and publish an article", async ({seed, as}) => {
await page.goto(`${spacePath(url)}/articles`) await page.goto(`${spacePath(url)}/articles`)
await expect(articleCards(page)).toHaveCount(2) await expect(articleCards(page).filter({hasText: "Signals in the Noise"})).toBeVisible()
await expect(articleCards(page).first()).toContainText("Signals in the Noise")
// A published article leaves the composer empty for the next one. // A published article leaves the composer empty for the next one.
await pageBar(page).getByRole("button", {name: "Write"}).click() await pageBar(page).getByRole("button", {name: "Write"}).click()
@ -171,11 +113,7 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
const body = [ const body = [
"## Why gardens matter", "## Why gardens matter",
"", "",
"Gardens are **worth the trouble**.", "Gardens are worth the trouble.",
"",
"- Soil",
"- Water",
"- Sunlight",
"", "",
`Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`, `Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`,
].join("\n") ].join("\n")
@ -239,61 +177,22 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
.renderTemplate(), .renderTemplate(),
at(2, HOUR), 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",
"keepnostrweird",
"longform",
"recommendations",
"wintering",
"shortdays",
])
.setPublishedAt(at(1, HOUR))
.setContent("Start with the shortest one.")
.renderTemplate(),
at(1, HOUR),
)
}) })
const {at} = scenario
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`) const page = await as(users.bob, `${spacePath(url)}/articles`)
const garden = articleCards(page).filter({hasText: "Tending the Garden"}) const garden = articleCards(page).filter({hasText: "Tending the Garden"})
await expect(articleCards(page)).toHaveCount(4) await expect(articleCards(page)).toHaveCount(3)
// 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("Alice Anderson")
await expect(garden).toContainText("A short teaser about gardens.") await expect(garden).toContainText("A short teaser about gardens.")
await expect(garden).toContainText(await shortDate(page, at(4, HOUR)))
// A card with more topics than fit on one line wraps them, rather than widening its action row
// until the reactions and the action menu fall off the card's edge.
const winter = articleCards(page).filter({hasText: "Winter Reading"})
const winterBox = (await winter.boundingBox())!
const winterActions = (await winter.locator('[data-component="ArticleActions"]').boundingBox())!
expect(winterActions.x + winterActions.width).toBeLessThanOrEqual(winterBox.x + winterBox.width)
const authors = page const authors = page
.locator("section") .locator("section")
.filter({has: page.getByRole("heading", {name: "Authors"})}) .filter({has: page.getByRole("heading", {name: "Authors"})})
const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})}) 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 topics.getByRole("button", {name: /^#gardening/}).click()
await expect(articleCards(page)).toHaveCount(2) await expect(articleCards(page)).toHaveCount(2)
await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible() await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible()
@ -305,17 +204,11 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
await openArticle(page, "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.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
// The byline pairs the published date with the reading time, so the header is what carries it.
await expect(page.locator("article header")).toContainText(await shortDate(page, at(4, HOUR)))
const markdown = page.locator(".content-markdown") const markdown = page.locator(".content-markdown")
await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible() 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() await expect(markdown.getByRole("link", {name: "@Carol Chen"})).toBeVisible()
}) })
@ -358,13 +251,10 @@ test("US-039 comment on an article", async ({seed, as}) => {
await noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.") await noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
await composerForm(bob).getByRole("button", {name: "Comment"}).click() await composerForm(bob).getByRole("button", {name: "Comment"}).click()
// The comment renders from the optimistic write, but the composer holds what was typed until the // The composer holds what was typed until the relay confirms it, so the page carries this text twice.
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible() await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
// A comment on a room event is a room event, so it carries the room the root lives in. Without // A comment on a room event is a room event, so without the room tag the relay sees no group.
// that tag the relay doesn't see it as part of the group, and neither its access rules nor a
// room deletion ever reach it.
await expect await expect
.poll(() => .poll(() =>
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)), getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
@ -389,45 +279,16 @@ test("US-039 comment on an article", async ({seed, as}) => {
const alice = await as(users.alice, articlesPath) const alice = await as(users.alice, articlesPath)
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await openArticle(alice, "Tending the Garden") 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. // A reply is nested inside the comment it answers; a top level comment is not.
const nested = alice.locator('[data-component="CommentReplies"]') const nested = alice.locator('[data-component="CommentReplies"]')
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible() 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 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 noteEditor(composer).pressSequentially("Thanks both, here's the bed I meant.")
await noteEditor(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 = comment(alice, "Thanks both, here's the bed I meant.")
await expect(alicesComment).toBeVisible()
await expect(alicesComment.locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
}) })
test("US-040 react to a post with an emoji", async ({seed, as}) => { test("US-040 react to a post with an emoji", async ({seed, as}) => {
let thread!: Seeded
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -437,10 +298,6 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
space.profile(user.alice, {name: "Alice Anderson"}) space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"}) 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.relayList(user.alice)
const article = space.event( const article = space.event(
user.alice, user.alice,
() => () =>
@ -467,39 +324,6 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
.renderTemplate(), .renderTemplate(),
at(3, HOUR), 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 {url} = scenario.space("space")
@ -508,7 +332,7 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
await openArticle(page, "Tending the Garden") await openArticle(page, "Tending the Garden")
// An article's own reactions live in the action bar under it rather than on the article itself. // An article's own reactions live in the action bar under it rather than on the article itself.
const articleActions = page.locator('[data-component="ArticleActions"]') const articleActions = page.locator('[data-component="ArticleActionBar"]')
const commentCard = comment(page, "A note about the soil") const commentCard = comment(page, "A note about the soil")
await expect(articleActions).toBeVisible() await expect(articleActions).toBeVisible()
@ -520,24 +344,6 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
await expect(commentCard).toBeVisible() await expect(commentCard).toBeVisible()
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard)) 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}) => { test("US-041 publish an article from a room", async ({seed, as}) => {
@ -561,9 +367,7 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible() await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
// The room hears about the article without alice posting it a second time. Its copy is published // The room's copy is published after the composer has moved on, and a page that unloads takes it.
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
// takes it with it.
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1) await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
await page.goto(roomPath(url, "lounge")) await page.goto(roomPath(url, "lounge"))
@ -573,67 +377,18 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
const card = articleCards(page).filter({hasText: "Repotting in Winter"}) const card = articleCards(page).filter({hasText: "Repotting in Winter"})
await expect(card).toBeVisible()
await expect(card.getByRole("link", {name: /#\s*Lounge/})).toBeVisible() await expect(card.getByRole("link", {name: /#\s*Lounge/})).toBeVisible()
await openArticle(page, "Repotting in Winter")
// A card says which room an article was posted in; the article's own page carries that in its
// page bar instead.
const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/})
await expect(roomLink).toBeVisible()
await roomLink.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}) => { test("US-042 start a thread and see it filed under its room", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
space.room("lounge", {name: "Lounge"}) space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge") space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.profile(user.alice, {name: "Alice Anderson"}) 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 {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "lounge")) const page = await as(users.alice, roomPath(url, "lounge"))
@ -654,24 +409,14 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await page.goto(`${spacePath(url)}/threads`) await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})}) 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() 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(
await shortDate(page, at(4, HOUR) + 60),
)
const general = page const general = page
.locator("section") .locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})}) .filter({has: page.getByRole("heading", {name: "General", exact: true})})
// Each board creates its own threads, so the room comes from the button that was clicked rather // Each board creates its own threads, so the room comes from the button that was clicked.
// than from a picker.
await general.getByRole("button", {name: "Create", exact: true}).click() await general.getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modalForm(page, "Create a Thread") const fromThreads = modalForm(page, "Create a Thread")
@ -683,18 +428,6 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0) await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible() await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible()
await lounge.getByRole("button", {name: "Create", exact: true}).click()
const toLounge = modalForm(page, "Create a Thread")
await toLounge.getByPlaceholder("What is this thread about?").fill("Carpet swatches")
await noteEditor(toLounge).pressSequentially("Beige is a choice.")
await toLounge.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
await expect(lounge.getByRole("row").filter({hasText: "Carpet swatches"})).toBeVisible()
}) })
test("US-043 reply to a thread and to a specific post", async ({seed, as}) => { test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
@ -724,22 +457,18 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
at(3, HOUR), at(3, HOUR),
) )
// Twenty replies is exactly the window a thread opens with, and the last of them is alice's, space.event(
// so her OP badge has to survive bob's own reply pushing the oldest one out of it. user.bob,
for (let i = 1; i <= 20; i++) { () =>
space.event( space
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob, .kind(Comment)
() => .writer()
space .setRootFromEvent(topic.event)
.kind(Comment) .setParentFromEvent(topic.event)
.writer() .setContent("Every other season.")
.setRootFromEvent(topic.event) .renderTemplate(),
.setParentFromEvent(topic.event) at(2, HOUR),
.setContent(`Reply ${String(i).padStart(2, "0")}`) )
.renderTemplate(),
at(3, HOUR) + i * 60,
)
}
thread = topic thread = topic
}) })
@ -748,77 +477,37 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
const threadPath = `${spacePath(url)}/threads/${thread.id}` const threadPath = `${spacePath(url)}/threads/${thread.id}`
const bob = await as(users.bob, threadPath) const bob = await as(users.bob, threadPath)
const openingPost = bob.locator(`[data-event="${thread.id}"]`) await expect(bob.locator(`[data-event="${thread.id}"]`)).toContainText("Bed rotation")
await expect(openingPost).toBeVisible()
await expect(openingPost.getByText("OP", {exact: true})).toBeVisible()
await expect(bob.getByText("20 replies")).toBeVisible()
// The opening post carries the title, and the summary bar replaces it once it scrolls away.
await expect(openingPost).toContainText("Bed rotation")
await bob.getByRole("button", {name: "Reply to thread"}).click() await bob.getByRole("button", {name: "Reply to thread"}).click()
const threadReply = composerForm(bob) const threadReply = composerForm(bob)
await expect(threadReply).toBeVisible()
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
await noteEditor(threadReply).pressSequentially("Twice a year here.") await noteEditor(threadReply).pressSequentially("Twice a year here.")
await threadReply.getByRole("button", {name: "Post Reply"}).click() await threadReply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("21 replies")).toBeVisible()
await expect(bob.getByText("Twice a year here.")).toBeVisible() await expect(bob.getByText("Twice a year here.")).toBeVisible()
// The same for a thread reply, which goes out through a different composer. // A thread reply goes out through its own composer, which needs the room tag too.
await expect await expect
.poll(() => .poll(() =>
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)), getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
) )
.toEqual(["lounge"]) .toEqual(["lounge"])
// His is the twenty first reply, so the oldest one drops out of the window, and the opening
// post stays above whatever the window holds.
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
await expect(showEarlier).toBeVisible()
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
await expect(openingPost).toBeVisible()
// The thread's author is marked OP wherever their posts turn up.
const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"})
await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible()
await showEarlier.click()
await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
await expect(showEarlier).toHaveCount(0)
const carol = await as(users.carol, threadPath) const carol = await as(users.carol, threadPath)
const bobsPost = carol.locator("article").filter({hasText: "Every other season."})
await carol.getByRole("button", {name: "Show earlier replies"}).click() await bobsPost.getByRole("button", {name: "Reply", exact: true}).click()
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 postReply = composerForm(carol)
const replyingTo = postReply.locator(".border-l-2")
await expect(replyingTo).toContainText("Replying to @Bob Barker") await expect(postReply).toContainText("Replying to @Bob Barker")
await expect(replyingTo).toContainText("Reply 01")
await replyingTo.getByRole("button").last().click() await noteEditor(postReply).pressSequentially("Every season here.")
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
await noteEditor(postReply).pressSequentially("Answering the thread instead.")
await postReply.getByRole("button", {name: "Post Reply"}).click() await postReply.getByRole("button", {name: "Post Reply"}).click()
// Her post lands at the end of the one list, so there is nothing to click to reach it. await expect(carol.getByText("Every season here.")).toBeVisible()
await expect(carol.getByText("Answering the thread instead.")).toBeVisible()
}) })
test("US-044 navigate a long thread", async ({seed, as}) => { test("US-044 navigate a long thread", async ({seed, as}) => {
@ -880,26 +569,18 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"}) const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
// Every assertion below is about which of the replies are in the window, so wait until they // Every assertion below is about which of the replies are in the window, so wait for all of them.
// have all arrived.
await expect(bob.getByText("41 replies")).toBeVisible() await expect(bob.getByText("41 replies")).toBeVisible()
// The thread opens on its newest twenty replies, with the opening post above them. // The thread opens on its newest twenty replies.
await expect(bob.locator(`[data-event="${thread.id}"]`)).toBeVisible()
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible() await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible()
await expect(bob.getByText("Reply 21", {exact: true})).toHaveCount(0) await expect(bob.getByText("Reply 21", {exact: true})).toHaveCount(0)
// The control says how many are still above it, so the reveal is not a blind click.
await expect(showEarlier).toHaveText("Show earlier replies (21)")
// Each reveal reaches twenty further back without leaving the page. // Each reveal reaches twenty further back without leaving the page.
await showEarlier.click() await showEarlier.click()
await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible() await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible()
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0) await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
await expect(showEarlier).toHaveText("Show earlier replies (1)")
await showEarlier.click() await showEarlier.click()
@ -909,12 +590,11 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`) const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`)
await oldestPost.getByRole("button", {name: "Permalink"}).click() await oldestPost.getByRole("button", {name: "Permalink"}).click()
await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!") await expect(bob.getByRole("alert")).toBeVisible()
const permalink = new URL(await bob.evaluate(() => navigator.clipboard.readText())) const permalink = new URL(await bob.evaluate(() => navigator.clipboard.readText()))
expect(permalink.pathname).toBe(threadPath) expect(permalink.pathname).toBe(threadPath)
expect(permalink.search).toBe(`?event=${nip19.neventEncode({id: firstReply.id, relays: [url]})}`)
// A permalink reaches back as far as it has to on its own, so carol never sees the control. // A permalink reaches back as far as it has to on its own, so carol never sees the control.
const carol = await as(users.carol, permalink.pathname + permalink.search) const carol = await as(users.carol, permalink.pathname + permalink.search)
@ -953,11 +633,6 @@ test("US-045 turn a chat message into a thread", async ({seed, as}) => {
await page.getByRole("button", {name: "Create a Thread"}).click() await page.getByRole("button", {name: "Create a Thread"}).click()
const composer = modalForm(page, "Create a Thread") const composer = modalForm(page, "Create a Thread")
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]})
// The seeded entity is parsed, so the composer shows the editor's chip for it rather than
// the raw uri — which is also what makes the thread carry a q tag for the message.
await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`)
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures") await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")
await composer.getByRole("button", {name: "Create Thread"}).click() await composer.getByRole("button", {name: "Create Thread"}).click()
@ -968,11 +643,8 @@ test("US-045 turn a chat message into a thread", async ({seed, as}) => {
await page.goto(`${spacePath(url)}/threads`) await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})}) 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 lounge.getByRole("row").filter({hasText: "Deploy failures"}).click()
await row.click()
await expect(page.locator("article").filter({hasText: "Deploy failures"})).toBeVisible() await expect(page.locator("article").filter({hasText: "Deploy failures"})).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible() await expect(page.getByText("the deploy broke again")).toBeVisible()

View file

@ -37,7 +37,6 @@ test("authenticates over nip-42 before a members-only relay serves anything", as
pubkey: users.alice.pubkey, pubkey: users.alice.pubkey,
}) })
// The whole point of intercepting the transport rather than swapping in an adapter: until the // Until the client proves who it is, the relay hands it nothing at all.
// client proves who it is, the relay hands it nothing at all.
expect(firstEventAt).toBeGreaterThan(authenticatedAt) expect(firstEventAt).toBeGreaterThan(authenticatedAt)
}) })

File diff suppressed because it is too large Load diff

View file

@ -24,42 +24,15 @@ import {
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item") const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
// RoomCompose leads with a two button group: upload, then the create menu. // A room composer leads with a two button group: upload, then the create menu.
const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first() const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first()
const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']") 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 // The reply preview and the editing indicator are the same bordered strip, each with its own X.
// indicator — are the same bordered strip, each with a single button in it: its X.
const banner = (page: Page, text: string) => const banner = (page: Page, text: string) =>
page.locator(".room__compose .border-l-2").filter({hasText: text}) page.locator(".room__compose .border-l-2").filter({hasText: text})
// 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_BASE64},
)
const pasteImage = (editor: Locator, name: string) => const pasteImage = (editor: Locator, name: string) =>
editor.evaluate( editor.evaluate(
(node, {name, data}) => { (node, {name, data}) => {
@ -91,8 +64,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
space.relayList(user.bob) space.relayList(user.bob)
space.message(user.bob, "general", "morning all", at(2, HOUR)) 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 // Someone matching the same term from a different space, to rank below this space's own members.
// dropdown has a non-member to rank below this space's own members.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge") other.join(user.alice, "lounge")
other.join(outsider, "lounge") other.join(outsider, "lounge")
@ -104,9 +76,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
const space = scenario.space("space") const space = scenario.space("space")
const other = scenario.space("other") const other = scenario.space("other")
// Alice arrives through the other space so that the outsider's profile is in her client before // A profile reaches her by being rendered, and his never renders in the space she is typing in.
// 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")) const page = await as(users.alice, roomPath(other.url, "lounge"))
// Exactly, since the join notice above his message carries his name too, as "@Bobbin Amaranth". // Exactly, since the join notice above his message carries his name too, as "@Bobbin Amaranth".
@ -114,8 +84,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
timeline(page).getByRole("button", {name: "Bobbin Amaranth", exact: true}), timeline(page).getByRole("button", {name: "Bobbin Amaranth", exact: true}),
).toBeVisible() ).toBeVisible()
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants // A space's nav item is labeled with the name its nip-11 document reports.
// report "space" and "other".
await page.locator('.primary-nav [data-tip="space"]').click() await page.locator('.primary-nav [data-tip="space"]').click()
await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click() await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click()
@ -129,17 +98,6 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
// This space's own member first, the outsider from the other space under him. // This space's own member first, the outsider from the other space under him.
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"]) 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() await suggestions(page).filter({hasText: "Bob Roberts"}).click()
const mention = editor.locator(".tiptap-object") const mention = editor.locator(".tiptap-object")
@ -201,31 +159,23 @@ test("US-057 attach and send an image", async ({seed, as}) => {
await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif")) await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif"))
await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible()
await expect(sendButton(alice)).toBeDisabled() await expect(sendButton(alice)).toBeDisabled()
blossom.release() blossom.release()
await expect(sendButton(alice)).toBeEnabled() await expect(sendButton(alice)).toBeEnabled()
await expect(composer(alice)).toContainText("photo.gif") await expect(composer(alice)).toContainText("photo.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await composer(alice).press("Enter") await composer(alice).press("Enter")
await expect(timeline(alice).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible() await expect(timeline(alice).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await expect(timeline(bob).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible() await expect(timeline(bob).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await dropImage(composer(alice), "dropped.gif")
await expect(composer(alice)).toContainText("dropped.gif")
await pasteImage(composer(alice), "pasted.gif") await pasteImage(composer(alice), "pasted.gif")
await expect(composer(alice)).toContainText("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 // Its composer stays disabled until the recipient's messaging relays have been read.
// relays have been read, which is what waiting on the composer waits out.
await alice.goto(chatPath(users.bob.pubkey)) await alice.goto(chatPath(users.bob.pubkey))
await bob.goto(chatPath(users.alice.pubkey)) await bob.goto(chatPath(users.alice.pubkey))
@ -235,41 +185,27 @@ test("US-057 attach and send an image", async ({seed, as}) => {
await expect(composer(alice)).toContainText("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 // Submit returns without a word while `uploading` is true, and encryption makes that window wide.
// 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 expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter") await composer(alice).press("Enter")
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and // A conversation's image is uploaded encrypted, so the recipient decrypts it into a blob url.
// decrypts it into a blob url rather than pointing an <img> at the server.
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible() 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 // Back to the room on a fresh page, so no toast the conversation raised is still standing.
// one asserted below can only be the rejected upload's own.
await alice.goto(path) await alice.goto(path)
await expect(timeline(alice).getByText("morning all")).toBeVisible() await expect(timeline(alice).getByText("morning all")).toBeVisible()
await expect(alice.getByRole("alert")).toHaveCount(0) await expect(alice.getByRole("alert")).toHaveCount(0)
// A file the editor has no node for is uploaded on its own and lands as a url.
await chooseFile(alice, roomUploadButton(alice), {
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("not an image"),
})
await expect(composer(alice)).toContainText(DEFAULT_BLOSSOM_ORIGIN)
await expect(alice.getByRole("alert")).toHaveCount(0)
// What the server refuses is what the composer refuses, in the words the server used. // What the server refuses is what the composer refuses, in the words the server used.
blossom.refuse("text files are not allowed here") blossom.refuse("text files are not allowed here")
await chooseFile(alice, roomUploadButton(alice), { await chooseFile(alice, roomUploadButton(alice), {
name: "notes-again.txt", name: "notes.txt",
mimeType: "text/plain", mimeType: "text/plain",
buffer: Buffer.from("still not an image"), buffer: Buffer.from("not an image"),
}) })
await expect(alice.getByRole("alert")).toContainText("text files are not allowed here") await expect(alice.getByRole("alert")).toContainText("text files are not allowed here")
@ -285,8 +221,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
space.join(user.bob, "general") space.join(user.bob, "general")
space.message(user.bob, "general", "morning all", at(2, HOUR)) 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 kind-10050 lets the Messages nav navigate; his enables the conversation's composer.
// her to enable chat; bob's is what enables the conversation's composer.
for (const person of [user.alice, user.bob]) { for (const person of [user.alice, user.bob]) {
space.profile(person, {name: person.name}) space.profile(person, {name: person.name})
space.relayList(person) space.relayList(person)
@ -294,8 +229,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
} }
}) })
// Drafts live in memory, so every move here is an in-app navigation — a reload would clear them // Drafts live in memory, so every move here is an in-app navigation.
// whether or not they were kept.
const page = await as(users.alice, chatPath(users.bob.pubkey)) const page = await as(users.alice, chatPath(users.bob.pubkey))
const editor = composer(page) const editor = composer(page)
const rooms = page.locator(".secondary-nav") const rooms = page.locator(".secondary-nav")
@ -331,8 +265,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
await expect(editor).toHaveText("half a thought") await expect(editor).toHaveText("half a thought")
// The composer is remounted around a restored draft, so put the caret in it before sending // The composer is remounted around a restored draft, so put the caret in it before sending.
// rather than typing into whatever had focus when the room came back.
await editor.click() await editor.click()
await editor.press("Enter") await editor.press("Enter")
@ -373,10 +306,6 @@ test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => {
await editor.pressSequentially("half-written thought") 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 messageActions(page, "morning all").nth(2).click()
await expect(replyBanner).toContainText("morning all") await expect(replyBanner).toContainText("morning all")
@ -413,11 +342,6 @@ test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => {
await expect(editBanner).toHaveCount(0) await expect(editBanner).toHaveCount(0)
await expect(editor).toHaveText("half-written thought") 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)
}) })
// Dictation's two buttons are one button in two states, so the label is what says which. // Dictation's two buttons are one button in two states, so the label is what says which.
@ -449,8 +373,7 @@ test("US-125 dictate a message", async ({seed, as}) => {
const alice = await as(users.alice, roomPath(url, "general")) const alice = await as(users.alice, roomPath(url, "general"))
const transcription = await mockOpenRouterTranscription(alice.context(), "the tide turns at six") const transcription = await mockOpenRouterTranscription(alice.context(), "the tide turns at six")
// Recording asks for nothing. Asking for a transcript with no key saved asks for one, the same // Recording asks for nothing. Asking for a transcript with no key saved asks for one.
// prompt reading a message out loud uses.
const action = await record(alice) const action = await record(alice)
await action.getByRole("button", {name: "Transcribe it"}).click() await action.getByRole("button", {name: "Transcribe it"}).click()
@ -467,22 +390,19 @@ test("US-125 dictate a message", async ({seed, as}) => {
await expect(composer(alice)).toContainText("the tide turns at six") await expect(composer(alice)).toContainText("the tide turns at six")
// OpenRouter picks its decoder off the extension, so what the recorder produced has to reach it // OpenRouter picks its decoder off the extension, so the upload has to name the format.
// under a name that names the format.
expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)]) expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)])
await composer(alice).press("Enter") await composer(alice).press("Enter")
await expect(timeline(alice)).toContainText("the tide turns at six") await expect(timeline(alice)).toContainText("the tide turns at six")
// A transcription outlives the composer that asked for it: the room it was recorded in can be // A transcription outlives the composer that asked for it, and waits for whichever comes next.
// left while the request is still out, and the transcript waits for whichever composer is next.
transcription.hold() transcription.hold()
await (await record(alice)).getByRole("button", {name: "Transcribe it"}).click() await (await record(alice)).getByRole("button", {name: "Transcribe it"}).click()
// In-app rather than a fresh load: a dictation is held by the app rather than by the composer // A dictation is held by the app rather than by the composer, so reloading the page is losing it.
// that started one, so reloading the page is losing it rather than leaving it.
await roomLink(alice, "Lounge").click() await roomLink(alice, "Lounge").click()
await expect(composer(alice)).toBeVisible() await expect(composer(alice)).toBeVisible()
@ -507,8 +427,7 @@ test("US-126 send a voice note", async ({seed, as}) => {
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN}) await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
// Leaving the question unanswered throws the recording away, so nothing is uploaded and the // Leaving the question unanswered throws the recording away.
// composer is where it was.
await (await record(alice)).getByRole("button", {name: "Discard"}).click() await (await record(alice)).getByRole("button", {name: "Discard"}).click()
await expect(dialog(alice, "Transcribe or send?")).toHaveCount(0) await expect(dialog(alice, "Transcribe or send?")).toHaveCount(0)
@ -521,7 +440,6 @@ test("US-126 send a voice note", async ({seed, as}) => {
await composer(alice).press("Enter") await composer(alice).press("Enter")
// The imeta on the message says the upload is audio, which is what gives it a player rather // The imeta on the message says the upload is audio, which gives it a player rather than a link.
// than a link.
await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible() await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
}) })

View file

@ -1,32 +1,18 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19" import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib" import {HOUR, MINUTE} from "@welshman/lib"
import {MESSAGE, makeEvent} from "@welshman/util" import {MESSAGE, NOTE, makeEvent} from "@welshman/util"
import {Article, Thread} from "@welshman/domain" import {Article, Thread} from "@welshman/domain"
import {expect, roomPath, spacePath, test, users} from "../harness" import {dialog, expect, message, roomPath, spacePath, test, users} from "../harness"
import type {SeededEvent} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue. // @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows.
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 = const INVOICE =
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" +
"ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" + "ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" +
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9" "agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
// An event id no fixture publishes. A quote card is in its loading state until the quoted event
// arrives, and only one that never arrives holds it there long enough to assert: the relay is a
// container on loopback, so a quote it can answer resolves before a locator has resolved.
const UNKNOWN_EVENT_ID = "6f1ac4b0d2e37f5981c6ab4e2d0937fc85be1a2d3c4f5061728394a5b6c7d8e9"
// 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}) => { test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
let flagged!: Seeded let flagged!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -34,7 +20,6 @@ test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.message(user.alice, "general", "no spoilers please", at(45, MINUTE))
flagged = space.event( flagged = space.event(
user.bob, user.bob,
@ -52,8 +37,6 @@ test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general")) 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 message = page.locator(`[data-event="${flagged.id}"]`)
const warning = message.getByText('flagged by the author as "spoilers"') const warning = message.getByText('flagged by the author as "spoilers"')
@ -110,14 +93,13 @@ test("US-061 expand a long post", async ({seed, as}) => {
await expect(page.getByText(paragraphs[11])).toBeVisible() await expect(page.getByText(paragraphs[11])).toBeVisible()
// The whole card is a link into the article, so expanding in place means the click never // The whole card is a link into the article, so expanding in place means the click never reached.
// reached it.
expect(page.url()).toBe(before) expect(page.url()).toBe(before)
}) })
test("US-062 see images and video inline", async ({seed, as}) => { test("US-062 see images and video inline", async ({seed, as}) => {
let picture!: Seeded let picture!: SeededEvent
let clip!: Seeded let clip!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -137,45 +119,25 @@ test("US-062 see images and video inline", async ({seed, as}) => {
const clipMessage = page.locator(`[data-event="${clip.id}"]`) const clipMessage = page.locator(`[data-event="${clip.id}"]`)
const inlineImage = pictureMessage.locator('img[src="https://images.test/sunset.png"]') 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() await inlineImage.click()
// The lightbox is a fullscreen modal holding nothing but the image.
const preview = page.getByRole("dialog", {name: "Content preview"}) const preview = page.getByRole("dialog", {name: "Content preview"})
const lightbox = preview.locator("img")
await expect(preview).toBeVisible() await expect(preview.locator("img")).toBeVisible()
await expect(lightbox).toBeVisible()
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(page.locator(".dialog")).toHaveCount(0) await expect(preview).toHaveCount(0)
await expect(inlineImage).toBeVisible() await expect(clipMessage.locator("video")).toBeVisible()
await inlineImage.click()
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}) => { test("US-063 preview a shared link", async ({seed, as}) => {
const announcement = "https://example.test/announcement" const announcement = "https://example.test/announcement"
const unreadable = "https://example.test/unreadable" const unreadable = "https://example.test/unreadable"
let standalone!: Seeded let standalone!: SeededEvent
let broken!: Seeded let broken!: SeededEvent
let sentence!: Seeded let sentence!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -196,26 +158,16 @@ test("US-063 preview a shared link", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
// The room is opened by hand below rather than by as(): a preview is fetched once per url and // A preview is fetched once per url and memoized for the life of the page.
// memoized for the life of the page, so the backend has to be standing before the first render.
const page = await as(users.alice, "/") const page = await as(users.alice, "/")
let servePreview = () => {} // Registered after as(), so it answers ahead of the harness's own dufflepud.
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( await page.context().route(
requestUrl => requestUrl.pathname === "/link/preview", requestUrl => requestUrl.pathname === "/link/preview",
async route => { async route => {
const {url: target}: {url: string} = route.request().postDataJSON() const {url: target}: {url: string} = route.request().postDataJSON()
if (target === announcement) { if (target === announcement) {
await previewReleased
return route.fulfill({ return route.fulfill({
json: { json: {
title: "Flotilla ships v1", title: "Flotilla ships v1",
@ -235,26 +187,17 @@ test("US-063 preview a shared link", async ({seed, as}) => {
const card = page.locator(`[data-event="${standalone.id}"]`) const card = page.locator(`[data-event="${standalone.id}"]`)
const failed = page.locator(`[data-event="${broken.id}"]`) const failed = page.locator(`[data-event="${broken.id}"]`)
const inline = page.locator(`[data-event="${sentence.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.getByRole("link", {name: "example.test/unreadable"})).toBeVisible()
servePreview()
await expect(card.getByText("Flotilla ships v1")).toBeVisible() await expect(card.getByText("Flotilla ships v1")).toBeVisible()
await expect(card.getByText("Everything new in this release.")).toBeVisible() await expect(failed.getByRole("link", {name: "example.test/unreadable"})).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 // The same url mid-sentence, now that the preview it would have shown is known to resolve.
// compact link, and none of the card.
await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible() await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0) 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}) => { test("US-064 follow a link to another space", async ({seed, as}) => {
let reference!: Seeded let reference!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -279,31 +222,21 @@ test("US-064 follow a link to another space", async ({seed, as}) => {
const other = scenario.space("other") const other = scenario.space("other")
const page = await as(users.alice, roomPath(url, "general")) const page = await as(users.alice, roomPath(url, "general"))
const message = page.locator(`[data-event="${reference.id}"]`) await page
const spaceLink = message.getByRole("link", {name: "other.test"}) .locator(`[data-event="${reference.id}"]`)
.getByRole("link", {name: "other.test"})
await expect(spaceLink).toBeVisible() .click()
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 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) expect(page.context().pages()).toHaveLength(1)
}) })
test("US-065 see quoted and embedded content", async ({seed, as}) => { test("US-065 see quoted and embedded content", async ({seed, as}) => {
let original!: Seeded let original!: SeededEvent
let reply!: Seeded let reply!: SeededEvent
let thread!: Seeded let thread!: SeededEvent
let threadQuote!: Seeded let threadQuote!: SeededEvent
let unresolvable!: Seeded
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -312,9 +245,6 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.join(user.carol, "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)) const point = space.message(user.alice, "general", "the original point", at(6, HOUR))
@ -323,10 +253,6 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
space.message(user.bob, "general", `filler message number ${i}`, at(300 - i * 5, MINUTE)) 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( const topic = space.event(
user.carol, user.carol,
() => () =>
@ -344,31 +270,15 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
thread = topic thread = topic
reply = space.reply(user.bob, point, "totally agree with this", at(20, MINUTE)) 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)) threadQuote = space.reply(user.bob, topic, "worth a read", at(15, MINUTE))
unresolvable = space.message(
user.bob,
"general",
`nostr:${neventEncode({id: UNKNOWN_EVENT_ID, relays: [space.url]})}\n\nand this one`,
at(10, MINUTE),
)
}) })
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general")) const page = await as(users.alice, roomPath(url, "general"))
const threadMessage = page.locator(`[data-event="${threadQuote.id}"]`)
// The state a quote card is in until the quoted event arrives. It is asserted on the quote
// nothing can answer rather than on the thread below, which resolves off an open socket to
// loopback and is as likely to be a card by the time this runs as a placeholder.
const unresolvableMessage = page.locator(`[data-event="${unresolvable.id}"]`)
await expect(unresolvableMessage.getByText("Loading event...")).toBeVisible()
const replyMessage = page.locator(`[data-event="${reply.id}"]`) const replyMessage = page.locator(`[data-event="${reply.id}"]`)
const quoteStrip = replyMessage.locator(".border-l-2") const quoteStrip = replyMessage.locator(".border-l-2")
await expect(quoteStrip).toContainText("the original point") await expect(quoteStrip).toContainText("the original point")
await expect(replyMessage).toContainText("totally agree with this")
const originalMessage = page.locator(`[data-event="${original.id}"]`) const originalMessage = page.locator(`[data-event="${original.id}"]`)
@ -379,34 +289,114 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
await expect(originalMessage).toBeInViewport() await expect(originalMessage).toBeInViewport()
// The quoted thread's card, once it has loaded: the profile circle beside the message carries await page.locator(`[data-event="${threadQuote.id}"]`).getByText("Roadmap for Q3").click()
// 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 page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
})
await expect(page.locator(`[data-event="${thread.id}"]`)).toContainText("Roadmap for Q3") test("US-140 open a quote before it has loaded", async ({seed, as}) => {
const late = "the tide tables are posted by the ramp"
let elsewhere!: SeededEvent
let fetched!: SeededEvent
let thread!: SeededEvent
const scenario = await seed(({relay, open, user, at}) => {
const space = relay("space")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Bob's note lives on a relay that answers nothing, so the quote naming it cannot resolve.
elsewhere = outbox.note(user.bob, "the ferry runs until eight", at(2, HOUR))
// No space view loads a kind 1, so a quote naming this one resolves only once it is asked for.
fetched = space.event(user.bob, () => makeEvent(NOTE, {content: late}), at(2, HOUR))
thread = space.event(
user.bob,
() =>
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),
)
// A quote surrounded by prose renders as a truncated label rather than as a card.
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `worth reading nostr:${neventEncode({...elsewhere.event, relays: [outbox.url]})} when you have a minute`,
tags: [["h", "general"]],
}),
at(20, MINUTE),
)
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `have a look at nostr:${neventEncode({...thread.event, relays: [space.url]})} before Friday`,
tags: [["h", "general"]],
}),
at(15, MINUTE),
)
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `one more nostr:${neventEncode({...fetched.event, relays: [space.url]})} if you are walking`,
tags: [["h", "general"]],
}),
at(10, MINUTE),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"), {
silent: [scenario.open("outbox").url],
})
const label = /^nevent1/
const quote = dialog(page, "Quote")
await message(page, "worth reading").getByRole("button", {name: label}).click()
await expect(quote).toContainText("Loading event...")
expect(page.context().pages()).toHaveLength(1)
await page.keyboard.press("Escape")
await expect(quote).toHaveCount(0)
await message(page, "one more").getByRole("button", {name: label}).click()
await expect(dialog(page, "Note")).toContainText(late)
await page.keyboard.press("Escape")
await expect(dialog(page, "Note")).toHaveCount(0)
await message(page, "have a look").getByRole("button", {name: label}).click()
await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
}) })
test("US-066 see distinctive inline tokens", async ({seed, as}) => { test("US-066 see distinctive inline tokens", async ({seed, as}) => {
const mention = `nostr:${npubEncode(users.bob.pubkey)}` const mention = `nostr:${npubEncode(users.bob.pubkey)}`
const content = [ const content = [
"Shipping #nostr today :partyparrot:", "Shipping today :partyparrot:",
`Run \`npm install\` first, then ping ${mention} or email team@example.test`, `Run \`npm install\` first, then ping ${mention}`,
"```", "```",
"const answer = 42", "const answer = 42",
"```", "```",
].join("\n") ].join("\n")
let tokens!: Seeded let tokens!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -421,8 +411,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
// Bob posts too, so his profile is loaded against this space before he is mentioned. // 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)) 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 // The shortcode's image comes off the message's own emoji tag.
// when someone picks a custom emoji.
tokens = space.event( tokens = space.event(
user.carol, user.carol,
makeEvent(MESSAGE, { makeEvent(MESSAGE, {
@ -441,37 +430,17 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
const message = page.locator(`[data-event="${tokens.id}"]`) const message = page.locator(`[data-event="${tokens.id}"]`)
await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/)
await expect(message.getByAltText(":partyparrot:")).toBeVisible() 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 await expect(message.locator("code").filter({hasText: "npm install"})).toBeVisible()
// no emoji tag to resolve, the shortcode is rendered verbatim. await expect(message.locator("code").filter({hasText: "const answer = 42"})).toBeVisible()
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() 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. await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
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}) => { test("US-067 copy a shared invoice or token", async ({seed, as}) => {
let invoice!: Seeded let invoice!: SeededEvent
let token!: Seeded
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -481,7 +450,6 @@ test("US-067 copy a shared invoice or token", async ({seed, as}) => {
space.join(user.bob, "general") space.join(user.bob, "general")
invoice = space.message(user.bob, "general", `lightning:${INVOICE}`, at(40, MINUTE)) 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 {url} = scenario.space("space")
@ -489,34 +457,11 @@ test("US-067 copy a shared invoice or token", async ({seed, as}) => {
context: {permissions: ["clipboard-read", "clipboard-write"]}, context: {permissions: ["clipboard-read", "clipboard-write"]},
}) })
const toast = page.getByRole("alert") await page
const invoiceChip = page
.locator(`[data-event="${invoice.id}"]`) .locator(`[data-event="${invoice.id}"]`)
.getByRole("button", {name: INVOICE.slice(0, 16)}) .getByRole("button", {name: INVOICE.slice(0, 16)})
.click()
await expect(invoiceChip).toBeVisible() await expect(page.getByRole("alert")).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) 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)
}) })

View file

@ -1,6 +1,6 @@
import {HOUR} from "@welshman/lib" import {HOUR} from "@welshman/lib"
import {LONG_FORM, MESSAGE} from "@welshman/util" import {LONG_FORM, MESSAGE} from "@welshman/util"
import {Article, Comment, Reaction} from "@welshman/domain" import {Article, Comment} from "@welshman/domain"
import type {Page} from "@playwright/test" import type {Page} from "@playwright/test"
import { import {
DEFAULT_BLOSSOM_ORIGIN, DEFAULT_BLOSSOM_ORIGIN,
@ -34,15 +34,13 @@ import type {SeededSpace, TestUser} from "../harness"
const articlePath = (url: string, address: string) => const articlePath = (url: string, address: string) =>
`${spacePath(url)}/articles/${encodeURIComponent(address)}` `${spacePath(url)}/articles/${encodeURIComponent(address)}`
// Outbox routing resolves everything about a person through their relay list, so a person here is // Outbox routing resolves a person through their relay list, so a person here has a kind-10002.
// a membership, a profile and a kind-10002.
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => { const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.profile(user, {name}) space.profile(user, {name})
space.relayList(user) space.relayList(user)
} }
// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its // A tippy is appended to the layout's own target and keeps its content mounted after it hides.
// 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}) const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true})
// A comment is a flat block in the comment tree rather than a card, named by its text. // A comment is a flat block in the comment tree rather than a card, named by its text.
@ -50,7 +48,7 @@ const commentCard = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text}) page.locator('[data-component="Comment"]').filter({hasText: text})
// The action bar under an article on its own page, which is where that article's status shows. // The action bar under an article on its own page, which is where that article's status shows.
const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]') const articleActions = (page: Page) => page.locator('[data-component="ArticleActionBar"]')
// Which relays a given event was sent to, oldest first — one entry per attempt. // Which relays a given event was sent to, oldest first — one entry per attempt.
const publishedTo = (page: Page, id: string) => const publishedTo = (page: Page, id: string) =>
@ -66,23 +64,24 @@ const writeArticle = async (page: Page, title: string, body: string) => {
await pageBar(page).getByRole("button", {name: "Publish"}).click() await pageBar(page).getByRole("button", {name: "Publish"}).click()
} }
// The comment composer, which is the only form on a content page carrying an editor.
const commentComposer = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")})
const writeComment = async (page: Page, body: string) => { const writeComment = async (page: Page, body: string) => {
await page.getByRole("button", {name: "Add a comment"}).click() await page.getByRole("button", {name: "Add a comment"}).click()
const form = page.locator("form").filter({has: page.locator(".note-editor")}) const form = commentComposer(page)
// The editor takes focus itself once it has mounted, and typing into it before that puts the // The editor takes focus itself once it has mounted, and typing before that puts the caret back.
// caret back at the start partway through the sentence.
await expect(noteEditor(form)).toBeFocused() await expect(noteEditor(form)).toBeFocused()
await noteEditor(form).pressSequentially(body) await noteEditor(form).pressSequentially(body)
await form.getByRole("button", {name: "Comment"}).click() await form.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 // The slider is initialised from the settings store on mount, so a fresh page says the client took it.
// 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 setSendDelay = async (page: Page, seconds: number) => {
const slider = page.locator("input[type=range]") const slider = page.getByRole("slider", {name: "Send delay"})
await page.goto("/settings/content") await page.goto("/settings/content")
await slider.fill(String(seconds * 1000)) await slider.fill(String(seconds * 1000))
@ -123,16 +122,8 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
const alice = await as(users.alice, path) const alice = await as(users.alice, path)
const bob = await as(users.bob, path) const bob = await as(users.bob, path)
await expect(timeline(alice).getByText("morning all")).toBeVisible()
await expect(timeline(bob).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 setSendDelay(alice, 5)
await alice.goto(path) await alice.goto(path)
@ -158,8 +149,7 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
await expect(message(alice, "wrong room, sorry")).toHaveCount(0) await expect(message(alice, "wrong room, sorry")).toHaveCount(0)
await expect(toast(alice)).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 // Sent after the cancelled one and delayed by as long, so its window has been and gone.
// cancelled one would have left in has been and gone.
await send(alice, "still here") await send(alice, "still here")
await expect(message(bob, "still here")).toBeVisible() await expect(message(bob, "still here")).toBeVisible()
@ -175,8 +165,7 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
await toast(alice).getByRole("button", {name: "Cancel"}).click() await toast(alice).getByRole("button", {name: "Cancel"}).click()
// That was her only message to him, so the conversation goes with it rather than staying in // That was her only message to him, so the conversation goes with it.
// the list with nothing left to name it by.
await expect(chatItems(alice)).toHaveCount(0) await expect(chatItems(alice)).toHaveCount(0)
await send(alice, "actually, hi") await send(alice, "actually, hi")
@ -202,8 +191,7 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => {
} }
space.messagingRelayList(user.alice) space.messagingRelayList(user.alice)
// Bob's client says his messages go to both relays, but he is not a member of the second, so a // Bob's client names both relays, but he is a member of one, so the other refuses a wrap for him.
// wrap addressed to him is stored by one and refused by the other.
space.messagingRelayList(user.bob, [space.url, other.url]) space.messagingRelayList(user.bob, [space.url, other.url])
space.message(user.bob, "general", "morning all", at(2, HOUR)) space.message(user.bob, "general", "morning all", at(2, HOUR))
@ -211,12 +199,10 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
// A room the relay never created, which is where a link to a room that has since been deleted // A room the relay never created, which is where a link to a room that has since been deleted lands.
// lands. Everything else about the space still works, so only this one publish is refused.
const ghost = roomPath(url, "archive") const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost) const alice = await as(users.alice, ghost)
const bob = await as(users.bob, ghost)
await send(alice, "anyone here?") await send(alice, "anyone here?")
@ -224,11 +210,6 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => {
await expect(failure).toBeVisible() 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 failure.click()
await expect(detail(alice)).toContainText("Failed to send!") await expect(detail(alice)).toContainText("Failed to send!")
@ -251,31 +232,33 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => {
await expect(detail(alice)).toContainText("space.test") await expect(detail(alice)).toContainText("space.test")
await expect(detail(alice)).toContainText("other.test") await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("restricted:") 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}) => { test("US-070 retry a failed relay", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
other.join(user.alice)
seedPerson(space, user.alice, "Alice Anderson") seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker") seedPerson(space, user.bob, "Bob Barker")
space.messagingRelayList(user.alice)
// Bob names both relays and belongs to one, so the other refuses a wrap addressed to him.
space.messagingRelayList(user.bob, [space.url, other.url])
}) })
const {url} = scenario.space("space") const {url} = scenario.space("space")
const ghost = roomPath(url, "archive") const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost) const alice = await as(users.alice, ghost)
// Opened now rather than once the room exists: seeding again below replaces the scenario, and a // Seeding again replaces the scenario, and a page opened from that one carries no room list.
// 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")) 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 // A delay is what makes the retry's own "Sending..." a state rather than an instant.
// watches a retry the way the person who asked for it does.
await setSendDelay(alice, 5) await setSendDelay(alice, 5)
await alice.goto(ghost) await alice.goto(ghost)
@ -285,18 +268,7 @@ test("US-070 retry a failed relay", async ({seed, as}) => {
await expect(failure).toBeVisible() await expect(failure).toBeVisible()
await failure.click() // Seeding again is the only way the relay changes its mind about something it has already refused.
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}) => { await seed(({relay}) => {
relay("space").room("archive", {name: "Archive"}) relay("space").room("archive", {name: "Archive"})
}) })
@ -307,14 +279,35 @@ test("US-070 retry a failed relay", async ({seed, as}) => {
await expect(toast(alice)).toContainText("Sending...") await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice)).toContainText("Message sent!") await expect(toast(alice)).toContainText("Message sent!")
// The message went out, so it no longer says it did not.
await expect(failure).toHaveCount(0)
const [sent] = getPublishedEvents(alice.context(), MESSAGE) const [sent] = getPublishedEvents(alice.context(), MESSAGE)
// One attempt and two retries, every one of them to the relay that failed and to nothing else. // The attempt and the retry, both to the relay that failed and to nothing else.
expect(publishedTo(alice, sent.id)).toEqual([url, url, url]) expect(publishedTo(alice, sent.id)).toEqual([url, url])
await bob.goto(roomPath(url, "archive")) await bob.goto(roomPath(url, "archive"))
await expect(message(bob, "is anyone here?")).toBeVisible() await expect(message(bob, "is anyone here?")).toBeVisible()
// The same retry in a conversation, where one of his two messaging relays turns her wrap away.
await alice.goto(chatPath(users.bob.pubkey))
await send(alice, "did that one arrive?")
const partial = alice.getByText("Failed to send!")
await expect(partial).toBeVisible()
await seed(({relay, user}) => {
relay("other").join(user.bob)
})
await partial.click()
await detail(alice).getByRole("button", {name: "Retry"}).click()
await expect(toast(alice)).toContainText("Message sent!")
await expect(partial).toHaveCount(0)
}) })
test("US-071 content posts show delivery status in place", async ({seed, as}) => { test("US-071 content posts show delivery status in place", async ({seed, as}) => {
@ -335,14 +328,12 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
const {url} = scenario.space("space") const {url} = scenario.space("space")
const quiet = scenario.space("other").url 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 // A space whose relay this browser will not open a socket to, so a post is sent and nothing returns.
// unconfirmed looks like from the inside: it is sent, and nothing comes back.
const alice = await as(users.alice, `${spacePath(url)}/articles`, { const alice = await as(users.alice, `${spacePath(url)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet}, env: {VITE_BLOCKED_RELAYS: quiet},
}) })
// A comment leaves after the send delay the way a chat message does, so the window in which its // A comment leaves after the send delay the way a chat message does.
// Cancel link is live is hers to set.
await setSendDelay(alice, 1) await setSendDelay(alice, 1)
await alice.goto(`${spacePath(url)}/articles`) await alice.goto(`${spacePath(url)}/articles`)
@ -350,26 +341,12 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
// Publishing lands on the article's own page, where its status sits under the article itself. // Publishing lands on the article's own page, where its status sits under the article itself.
await expect(alice.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible() await expect(alice.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
await expect(articleActions(alice).getByText("Sending...")).toHaveCount(0)
await expect(articleActions(alice).getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
await writeComment(alice, "Worth saying twice.")
const comment = commentCard(alice, "Worth saying twice.")
await expect(menuButton(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. // The same post into the space whose relay says nothing back.
await alice.goto(`${spacePath(quiet)}/articles`) await alice.goto(`${spacePath(quiet)}/articles`)
await writeArticle(alice, "Into the Void", "Nobody is listening.") await writeArticle(alice, "Into the Void", "Nobody is listening.")
// The composer holds the reader until publishing gives up, then lands on the article anyway. // Nothing was refused, so there is no toast; the action bar says the relay never answered.
// Nothing was refused, so there is no toast — the action bar under the article is what says the
// relay never answered.
const stuck = articleActions(alice) const stuck = articleActions(alice)
await expect(stuck.getByText("Failed to send!")).toBeVisible() await expect(stuck.getByText("Failed to send!")).toBeVisible()
@ -386,6 +363,10 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
const footnote = commentCard(alice, "A footnote nobody asked for.") const footnote = commentCard(alice, "A footnote nobody asked for.")
await expect(footnote.getByText("Sending...")).toBeVisible() await expect(footnote.getByText("Sending...")).toBeVisible()
// The comment is still in flight to a relay that says nothing, and the composer has already gone.
await expect(commentComposer(alice)).toHaveCount(0, {timeout: 2000})
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible() await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
await footnote.getByRole("button", {name: "Cancel"}).click() await footnote.getByRole("button", {name: "Cancel"}).click()
@ -419,7 +400,7 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
at(4, HOUR), at(4, HOUR),
) )
const comment = space.event( space.event(
user.alice, user.alice,
() => () =>
space space
@ -431,13 +412,6 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
.renderTemplate(), .renderTemplate(),
at(3, HOUR), 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 {url} = scenario.space("space")
@ -469,7 +443,6 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
const article = articleActions(alice) const article = articleActions(alice)
await expect(comment).toBeVisible() await expect(comment).toBeVisible()
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
await menuButton(comment).click() await menuButton(comment).click()
await alice.getByRole("button", {name: "Delete Comment"}).click() await alice.getByRole("button", {name: "Delete Comment"}).click()
@ -482,10 +455,8 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
await alice.getByRole("button", {name: "Delete Article"}).click() await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
// But this page is the article's own view, so it stays and is marked deleted rather than // This page is the article's own view, so it stays and is marked deleted rather than vanishing.
// vanishing, and the "Deleted" pill stands in place of the actions it offered before.
await expect(article.getByText("Deleted", {exact: true})).toBeVisible() await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0)
}) })
test("US-073 a multi-part message reports one status", async ({seed, as}) => { test("US-073 a multi-part message reports one status", async ({seed, as}) => {
@ -502,8 +473,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
space.messagingRelayList(user.alice) space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// Carol's client names a relay she does not belong to, so every part of a message to her is // Carol's client names a relay she does not belong to, so one of the two refuses each part.
// stored by one of her two relays and refused by the other.
space.messagingRelayList(user.carol, [space.url, other.url]) space.messagingRelayList(user.carol, [space.url, other.url])
}) })
@ -519,8 +489,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
await composer(alice).pressSequentially("here is the harbour") await composer(alice).pressSequentially("here is the harbour")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif")) await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif"))
// The editor names the file the moment it is attached, so the name alone does not mean the // The editor names the file the moment it is attached, and a submit while it uploads is dropped.
// 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)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled() await expect(sendButton(alice)).toBeEnabled()
@ -535,7 +504,6 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
// Which only becomes a success once both parts have landed everywhere they were sent. // 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)).toContainText("Message sent!")
await expect(toast(alice)).toHaveCount(1)
await expect(bob.locator(".chat-bubble")).toHaveCount(2) await expect(bob.locator(".chat-bubble")).toHaveCount(2)
await expect(toast(alice)).toHaveCount(0) await expect(toast(alice)).toHaveCount(0)

View file

@ -21,16 +21,11 @@ import {
roomPath, roomPath,
send, send,
test, test,
topDialog,
users, users,
} from "../harness" } from "../harness"
import type {SeededRumor, SeededSpace, TestUser} from "../harness" import type {SeededRumor, SeededSpace, TestUser} from "../harness"
// Everything about a person is resolved through their relay list — their profile, their messaging // A person here is a membership, a profile and a relay list, since everything resolves through one.
// 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, ...rooms: string[]) => { const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
space.join(user, ...rooms) space.join(user, ...rooms)
space.profile(user, {name}) space.profile(user, {name})
@ -46,35 +41,21 @@ const chatTab = (page: Page, name: string) =>
// ChatItem's unread mark is a bare dot with no text of its own. // 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 unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
// The "..." menu on a profile header, which is the only ghost circle button either the profile // The reply preview is a bordered strip above the composer.
// page or the profile modal renders.
const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost")
// 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})})
// 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 composePreview = (page: Page) => page.locator(".room__compose .border-l-2")
// A conversation names its messages by id rather than by text: the same words are sent more than // The same words are sent more than once in these stories, so only the id says which bubble is which.
// once in these stories, and only the id says which bubble is which.
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`) const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"}) 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 // The composer stays disabled until every recipient's messaging relays have been read.
// on it is part of sending rather than a wait for a wait's sake.
const sendDm = async (page: Page, content: string) => { const sendDm = async (page: Page, content: string) => {
await composerEnabled(page) await composerEnabled(page)
await send(page, content) await send(page, content)
} }
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the // A suggestion carries the pubkey it selects as its label, so the pubkey identifies the row.
// pubkey is what identifies the row that comes back.
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => { const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
await page.getByRole("button", {name: "Start New Chat"}).click() await page.getByRole("button", {name: "Start New Chat"}).click()
@ -83,9 +64,7 @@ const startChat = async (page: Page, people: {term: string; user: TestUser}[]) =
for (const {term, user} of people) { for (const {term, user} of people) {
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`) 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 // The suggestion list is rebuilt off the term changing, so a term arriving at once is matched once.
// 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 page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
await expect(suggestion).toContainText(term) await expect(suggestion).toContainText(term)
@ -96,49 +75,16 @@ const startChat = async (page: Page, people: {term: string; user: TestUser}[]) =
await page.getByRole("button", {name: "Create Chat"}).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 // A message's actions are a hover popover on a pointer device and named buttons on a touch one.
// buttons on a touch one, so a spec that wants to name them opens a touch context.
const openMessageMenu = (page: Page, id: string) => const openMessageMenu = (page: Page, id: string) =>
message(page, id).locator(".chat-bubble").click() 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 // Events reach indexeddb in three-second batches, so a spec that reloads has to read the cache first.
// 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,
)
// What this user's client has written to disk, of one kind. Events reach indexeddb in three-second
// batches with nothing in the ui to say when one has landed, so a spec that takes the relay away
// and reloads has to read the cache first — otherwise it passes or fails on the batch window rather
// than on what was persisted.
const cachedContent = async (page: Page, pubkey: string, kind: number) => const cachedContent = async (page: Page, pubkey: string, kind: number) =>
(await readCachedEvents(page, pubkey)) (await readCachedEvents(page, pubkey))
.filter(event => event.kind === kind) .filter(event => event.kind === kind)
.map(event => event.content) .map(event => event.content)
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}) => { test("US-029 start a one-on-one chat", async ({seed, as}) => {
await seed(({relay, user, at}) => { await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -149,16 +95,13 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
space.messagingRelayList(user.alice) space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// A conversation the three of them are already in. It puts bob's profile in her client before // A conversation the three of them are already in, which puts bob's profile in her client.
// 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)) space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
}) })
const page = await as(users.alice, "/chat") 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 // A group conversation's item in the list names only the first of its participants.
// 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 chatItems(page).filter({hasText: "welcome aboard"}).click()
await expect(pageBar(page)).toContainText("Bob Barnacle") await expect(pageBar(page)).toContainText("Bob Barnacle")
@ -166,50 +109,11 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
await startChat(page, [{term: "Barnacle", user: users.bob}]) await startChat(page, [{term: "Barnacle", user: users.bob}])
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(page)).toBeVisible()
await composerEnabled(page)
await sendDm(page, "hi bob") await sendDm(page, "hi bob")
await expect(bubble(page, "hi bob")).toBeVisible() await expect(bubble(page, "hi bob")).toBeVisible()
await expect(chatItems(page).filter({hasText: "hi bob"})).toContainText("Bob Barnacle")
// 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()
// The profile page's menu leaves messaging to the button beside it
await page.goto(profilePath(users.bob.pubkey))
await profileMenu(page).click()
await expect(page.getByRole("button", {name: "Profile Info"})).toBeVisible()
await expect(page.getByRole("button", {name: "Send Message"})).toHaveCount(0)
// The profile modal, which has no such button, carries the action in its menu instead
await page.goto("/chat")
await chatItems(page).filter({hasText: "welcome aboard"}).click()
await bubble(page, "welcome aboard").getByRole("button", {name: "Carol Cutter"}).first().click()
await profileMenu(topDialog(page)).click()
await page.getByRole("button", {name: "Send Message"}).click()
await expect(page).toHaveURL(pathPattern(chatPath(users.carol.pubkey)))
await expect(pageBar(page)).toContainText("Carol Cutter")
// 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}) => { test("US-030 start a group chat", async ({seed, as}) => {
@ -230,8 +134,7 @@ test("US-030 start a group chat", async ({seed, as}) => {
const page = await as(users.alice, "/chat") const page = await as(users.alice, "/chat")
// Their names rather than their npubs is the client saying it has both profiles, which is what // Their names rather than their npubs is the client saying it has both profiles.
// 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: "just us two"})).toContainText("Bob Barnacle")
await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter") await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter")
@ -247,24 +150,6 @@ test("US-030 start a group chat", async ({seed, as}) => {
// Labeled with both of them rather than with one // Labeled with both of them rather than with one
await expect(pageBar(page)).toContainText("Bob Barnacle") await expect(pageBar(page)).toContainText("Bob Barnacle")
await expect(pageBar(page)).toContainText("Carol Cutter") 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}) => { test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
@ -282,8 +167,6 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
// The prompt stands where the conversation she asked for would have been // The prompt stands where the conversation she asked for would have been
await expect(enablePrompt(alice)).toBeVisible() 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() await alice.getByRole("button", {name: "Enable direct messaging"}).click()
@ -292,9 +175,6 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
// Bob has never enabled it, so his end of the conversation is a dead end // 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 composerDisabled(alice) await composerDisabled(alice)
const bob = await as(users.bob, "/") const bob = await as(users.bob, "/")
@ -319,9 +199,6 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
// The prompt is behind her for good, and his end of it works now // 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(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(enablePrompt(alice)).toHaveCount(0)
await composerEnabled(alice)
await expect(banner).toHaveCount(0)
await sendDm(alice, "finally") await sendDm(alice, "finally")
@ -330,11 +207,7 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
}) })
test("US-032 exchange messages in a conversation", async ({seed, as}) => { test("US-032 exchange messages in a conversation", async ({seed, as}) => {
let yesterday!: SeededRumor await seed(({relay, user, at}) => {
let firstToday!: SeededRumor
let secondToday!: SeededRumor
const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor") seedPerson(space, user.alice, "Alice Anchor")
@ -342,52 +215,27 @@ test("US-032 exchange messages in a conversation", async ({seed, as}) => {
space.messagingRelayList(user.alice) space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR)) space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
// 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 alice = await as(users.alice, chatPath(users.bob.pubkey))
const bob = await as(users.bob, chatPath(users.alice.pubkey)) const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, yesterday.id)).toBeVisible() await expect(bubble(alice, "morning alice")).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 sendDm(alice, "just got here") await sendDm(alice, "just got here")
await expect(bubble(alice, "just got here")).toBeVisible() await expect(bubble(alice, "just got here")).toBeVisible()
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
await expect(composer(alice)).toHaveText("") await expect(composer(alice)).toHaveText("")
// ...and it reaches the conversation he already has open, attributed to her // 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") await expect(bubble(bob, "just got here")).toContainText("Alice Anchor")
}) })
test("US-033 browse and search your conversations", async ({seed, as}) => { test("US-033 browse and search your conversations", async ({seed, as}) => {
const dave = makeTestUser("dave") const dave = makeTestUser("dave")
const scenario = await seed(({relay, user, at}) => { await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor") seedPerson(space, user.alice, "Alice Anchor")
@ -405,29 +253,14 @@ test("US-033 browse and search your conversations", async ({seed, as}) => {
await expect(chatItems(page)).toHaveCount(3) await expect(chatItems(page)).toHaveCount(3)
const bobStamp = await stampLabel(page, scenario.at(30, MINUTE)) // Most recently active first
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("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("see you at the meetup")
await expect(chatItems(page).nth(2)).toContainText(daveStamp)
await chatFilter(page).fill("Bob Barnacle") await chatFilter(page).fill("Bob Barnacle")
await expect(chatItems(page)).toHaveCount(1) await expect(chatItems(page)).toHaveCount(1)
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle") 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}) => { test("US-034 track and clear unread conversations", async ({seed, as}) => {
@ -507,37 +340,13 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
hers = space.dm(user.alice, [user.bob], "first attempt", at(10, 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 // A touch context, which puts a message's actions behind named buttons rather than a hover popover.
// behind a row of icons in a hover popover.
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}}) const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
const bob = await as(users.bob, chatPath(users.alice.pubkey)) const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, his.id)).toBeVisible() await expect(message(alice, his.id)).toBeVisible()
await expect(message(alice, hers.id)).toBeVisible() await expect(message(alice, hers.id)).toBeVisible()
const desktopActions = message(bob, his.id).getByRole("button", {name: "Message actions"})
await desktopActions.focus()
await expect(desktopActions).toHaveCSS("opacity", "1")
await desktopActions.click()
await expect(bob.getByRole("button", {name: "Add a reaction"})).toBeVisible()
await expect(bob.getByRole("button", {name: "Reply"})).toBeVisible()
await expect(bob.getByRole("button", {name: "Message info"})).toBeVisible()
await bob.keyboard.press("Escape")
await expect(desktopActions).toBeFocused()
// Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "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 openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Reply"}).click() await alice.getByRole("button", {name: "Reply"}).click()
@ -549,38 +358,26 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?") 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?") 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. The menu // The menu leads with React and Reply and keeps the rest behind a disclosure.
// leads with React and Reply and keeps the rest behind a disclosure.
await openMessageMenu(alice, hers.id) await openMessageMenu(alice, hers.id)
await alice.getByRole("button", {name: "More Options"}).click() await alice.getByRole("button", {name: "More Options"}).click()
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
await alice.getByRole("button", {name: "Edit Message"}).click() await alice.getByRole("button", {name: "Edit Message"}).click()
await expect(composePreview(alice)).toContainText("Editing message")
await expect(composer(alice)).toContainText("first attempt") await expect(composer(alice)).toContainText("first attempt")
await composer(alice).press("ControlOrMeta+a") await composer(alice).press("ControlOrMeta+a")
await sendDm(alice, "second attempt") await sendDm(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(bubble(bob, "second attempt")).toBeVisible()
await expect(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0) 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 // React, then take it back, on both sides of the conversation
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "React"}).click() await alice.getByRole("button", {name: "React"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true}) 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 // A result's label joins the emoji, its annotation and every shortcode.
// annotation is what gets matched rather than the whole of it.
await picker.locator("input.search").fill("party popper") await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click() await picker.getByRole("option", {name: /party popper/}).click()
@ -589,16 +386,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await message(alice, his.id).getByRole("button", {name: "🎉"}).click() 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("🎉") 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}) => { test("US-036 receive a new conversation live", async ({seed, as}) => {
@ -611,8 +399,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
space.messagingRelayList(user.alice) space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// A conversation each of them already has. A list with something in it is how each page says // A list with something in it is how each page says its own end of the sync is up.
// 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.alice], "see you monday", at(4, HOUR))
space.dm(user.carol, [user.bob], "you too", at(4, HOUR)) space.dm(user.carol, [user.bob], "you too", at(4, HOUR))
}) })
@ -634,8 +421,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
await sendDm(bob, "starting a chat with you") await sendDm(bob, "starting a chat with you")
// His own copy first, so a send that lost keystrokes to something else on the page fails here // His own copy first, so a send that lost keystrokes fails here rather than thirty seconds later.
// rather than thirty seconds later as a message that never reached her
await expect(bubble(bob, "starting a chat with you")).toBeVisible() await expect(bubble(bob, "starting a chat with you")).toBeVisible()
// Her list picks the conversation up on its own // Her list picks the conversation up on its own
@ -656,8 +442,7 @@ test("US-128 keep messages from strangers out of your conversations", async ({se
const space = relay("space") const space = relay("space")
const inbox = relay("other") const inbox = relay("other")
// Bob she follows. Carol she knows only through the space they are both in, which is the // Carol she knows only through the space they are both in, the weakest thing that counts as knowing.
// weakest thing that still counts as knowing someone.
seedPerson(space, user.alice, "Alice Anchor") seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle") seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter") seedPerson(space, user.carol, "Carol Cutter")
@ -667,9 +452,7 @@ test("US-128 keep messages from strangers out of your conversations", async ({se
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
) )
// Dave and Eve write from a relay alice reads her messages on and has not joined, as US-108 // Membership of a relay never reaches a room list, so writing from it vouches for nobody.
// does. Membership of it is what lets a wrap addressed to someone be stored there, and it
// never reaches a room list, so writing from it vouches for nobody.
space.messagingRelayList(user.alice, [space.url, inbox.url]) space.messagingRelayList(user.alice, [space.url, inbox.url])
inbox.member(user.alice) inbox.member(user.alice)
inbox.member(dave) inbox.member(dave)
@ -684,19 +467,14 @@ test("US-128 keep messages from strangers out of your conversations", async ({se
inbox.dm(user.alice, [eve], "I am, who is this?", at(3, HOUR)) inbox.dm(user.alice, [eve], "I am, who is this?", at(3, HOUR))
}) })
// Only her space indexes, as in US-108: the socket a client opens before it knows what a relay // The socket a client opens before it knows what a relay is for never identifies itself to it.
// is for never identifies itself to it, and the inbox relay serves an anonymous reader nothing.
const page = await as(users.alice, "/chat", { const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
}) })
await expect(chatTab(page, "Conversations")).toContainText("3")
await expect(chatTab(page, "Requests")).toContainText("1")
await expect(chatItems(page)).toHaveCount(3) await expect(chatItems(page)).toHaveCount(3)
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?") await expect(chatItems(page).filter({hasText: "the hull is patched"})).toBeVisible()
await expect(chatItems(page).nth(1)).toContainText("the hull is patched") await expect(chatItems(page).filter({hasText: "I am, who is this?"})).toBeVisible()
await expect(chatItems(page).nth(2)).toContainText("I am, who is this?")
await chatTab(page, "Requests").click() await chatTab(page, "Requests").click()
@ -720,19 +498,13 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
seedPerson(space, user.bob, "Bob Barnacle") seedPerson(space, user.bob, "Bob Barnacle")
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and // Her messaging relay list is the one thing that vouches for her, and the relay serves her nothing until it does.
// not one of her read or write relays. Membership of it is only what lets a wrap addressed to
// her be stored there — it never reaches her room list — so her messaging relay list is the
// one thing that can vouch for her, and the relay serves her nothing until it does.
inbox.member(user.alice) inbox.member(user.alice)
// Bob's own copy of the wrap is seeded onto the same relay, and this relay authorizes a wrap by // This relay authorizes a wrap by the member its p tag names, so bob has to be one too.
// the member its p tag names, so he has to be one too. Nothing else here is his.
inbox.member(user.bob) inbox.member(user.bob)
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written // Tagged verbatim rather than through setUrls, which normalizes on the way in.
// by another client is where a url missing its trailing slash comes from, and the relay it
// names is the same relay either way.
space.event(user.alice, () => space.event(user.alice, () =>
space space
.kind(MessagingRelayList) .kind(MessagingRelayList)
@ -744,9 +516,7 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR)) inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR))
}) })
// Only her space indexes, so the inbox relay is dialled for her messages and nothing else — the // Only her space indexes, so the inbox relay is dialled for her messages and nothing else.
// socket a client opens before it knows what a relay is for never identifies itself to it, and
// this relay serves an anonymous reader nothing.
const page = await as(users.alice, "/chat", { const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
}) })
@ -778,16 +548,13 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
his = space.dm(user.bob, [user.alice], "the tide charts are up", at(2, HOUR)) his = space.dm(user.bob, [user.alice], "the tide charts are up", at(2, HOUR))
hers = space.dm(user.alice, [user.bob], "thakns", at(1, HOUR)) hers = space.dm(user.alice, [user.bob], "thakns", at(1, HOUR))
// The control on the relay having really forgotten. A room message comes off the same relay as // A room message comes off the same relay and is read back off the wire every time.
// the wraps and is the kind of thing a client reads back off the wire every time, so it is what
// a conversation that survives is being distinguished from.
space.message(user.bob, "general", "boat is in the water", at(2, HOUR)) space.message(user.bob, "general", "boat is in the water", at(2, HOUR))
}) })
const url = scenario.space("space").url const url = scenario.space("space").url
// A touch context, which is what puts Edit Message behind a named button. Editing is the only way // A touch context, which is what puts Edit Message behind a named button.
// the ui takes a direct message back, and the delete it publishes is the second half of the story.
const alice = await as(users.alice, roomPath(url, "general"), {context: {hasTouch: true}}) const alice = await as(users.alice, roomPath(url, "general"), {context: {hasTouch: true}})
await expect(alice.locator(".room__item").filter({hasText: "boat is in the water"})).toBeVisible() await expect(alice.locator(".room__item").filter({hasText: "boat is in the water"})).toBeVisible()
@ -809,8 +576,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
await expect(bubble(alice, "thanks")).toBeVisible() await expect(bubble(alice, "thanks")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0) await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0)
// A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or // A reaction travels gift-wrapped the way the messages do, so it is kept or lost with them.
// lost with them rather than on its own terms.
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "React"}).click() await alice.getByRole("button", {name: "React"}).click()
@ -821,8 +587,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
await expect(message(alice, his.id)).toContainText("🎉") await expect(message(alice, his.id)).toContainText("🎉")
// The edit, the delete that retracted the original and the reaction all reach disk in batches, so // The edit, the delete and the reaction all reach disk in batches, which is what the reload reads.
// waiting for them there is waiting for the whole of what the reload is about to read back.
await expect await expect
.poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE)) .poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE))
.toEqual(expect.arrayContaining(["the tide charts are up", "thanks"])) .toEqual(expect.arrayContaining(["the tide charts are up", "thanks"]))

View file

@ -0,0 +1,160 @@
import {HOUR} from "@welshman/lib"
import type {Locator, Page} from "@playwright/test"
import {composer, emojiButton, expect, message, roomPath, test, users} from "../harness"
const openReactionPicker = async (page: Page, opener: Locator) => {
await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true})
await expect(picker).toHaveCount(1)
return picker
}
const openComposerEmojiPicker = async (page: Page) => {
await page.getByRole("button", {name: "Add emoji"}).click()
const shell = page.locator('[data-component="EmojiPicker"]').filter({visible: true})
await expect(shell).toHaveCount(1)
return shell
}
const searchPicker = async (picker: Locator, annotation: string) => {
await picker.locator("input.search").fill(annotation)
await expect(picker.getByRole("option", {name: new RegExp(annotation)}).first()).toBeVisible()
}
test("Tippy reaction picker shows gestures and flags without CDN fetches", 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", "coverage check", at(2, HOUR))
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const cdnRequests: string[] = []
await alice.context().route(/jsdelivr|skypack|fonts\.googleapis|fonts\.gstatic/i, route => {
cdnRequests.push(route.request().url())
return route.continue()
})
const opener = emojiButton(message(alice, "coverage check"))
const picker = await openReactionPicker(alice, opener)
await searchPicker(picker, "heart hands")
await searchPicker(picker, "flag: India")
await alice.keyboard.press("Escape")
expect(cdnRequests).toEqual([])
})
test("reaction picker preserves skin tone", 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", "skin tone check", at(2, HOUR))
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const opener = emojiButton(message(alice, "skin tone check"))
const picker = await openReactionPicker(alice, opener)
await picker.getByRole("button", {name: /Choose a skin tone/}).click()
await picker.getByRole("option", {name: "Medium-Dark"}).click()
await searchPicker(picker, "thumbs up")
await picker
.getByRole("option", {name: /thumbs up/})
.first()
.click()
await expect(message(alice, "skin tone check").getByRole("button", {name: "👍🏾"})).toBeVisible()
})
test("composer inserts emoji and kaomoji at the caret and closes the popover", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const editor = composer(alice)
await editor.click()
await editor.pressSequentially("Hello world")
await editor.evaluate(node => {
const paragraph = node.querySelector("p")
const textNode = paragraph?.firstChild
if (!(textNode instanceof Text)) {
return
}
const range = document.createRange()
range.setStart(textNode, "Hello ".length)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
})
const shell = await openComposerEmojiPicker(alice)
const picker = shell.locator("emoji-picker")
await picker.locator("input.search").fill("grinning face")
await picker
.getByRole("option", {name: /grinning face/})
.first()
.click()
await expect(shell).toHaveCount(0)
await expect(editor).toBeFocused()
await expect(editor).toHaveText("Hello 😀world")
const kaomojiShell = await openComposerEmojiPicker(alice)
await kaomojiShell.getByRole("button", {name: "Kaomoji", exact: true}).click()
await kaomojiShell.getByRole("button", {name: "Shrug", exact: true}).click()
await expect(kaomojiShell).toHaveCount(0)
await expect(editor).toBeFocused()
await expect(editor).toContainText("¯\\_(ツ)_/¯")
})
test("Emoji → Kaomoji → Emoji still selects emoji", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const shell = await openComposerEmojiPicker(alice)
await shell.getByRole("button", {name: "Kaomoji", exact: true}).click()
await expect(shell.getByRole("button", {name: "Shrug", exact: true})).toBeVisible()
await shell.getByRole("button", {name: "Emoji", exact: true}).click()
const picker = shell.locator("emoji-picker")
await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click()
await expect(composer(alice)).toContainText("🎉")
})

View file

@ -1,16 +1,16 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19" import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {DAY, HOUR, MINUTE} from "@welshman/lib" import {DAY, HOUR, MINUTE} from "@welshman/lib"
import {POLL, displayRelayUrl} from "@welshman/util" import {POLL, displayRelayUrl} from "@welshman/util"
import {RelayMessageType} from "@welshman/net" import {Comment, FollowList, Note, Poll, SearchRelayList, Thread} from "@welshman/domain"
import {Classified, Comment, FollowList, Note, Poll, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import { import {
composer, composer,
dialog, dialog,
expect, expect,
getTranscript,
message, message,
messageActions, messageActions,
modalForm,
noteEditor,
openRoomDetail, openRoomDetail,
pageBar, pageBar,
pathPattern, pathPattern,
@ -24,54 +24,40 @@ import {
} from "../harness" } from "../harness"
import type {SeededEvent, SeededSpace, TestUser} from "../harness" import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// The unread indicator, which every surface renders the same way: a small primary-colored dot in // The primary nav draws its dot in utilities and the space menu's rooms carry secondary-nav__dot.
// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape const unreadDot = (scope: Locator) =>
// at h-5 w-5, so the size is part of what says which one this is. scope.locator("div.h-2.w-2.rounded-full.bg-primary, div.secondary-nav__dot")
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
// The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url, // An icon is a css mask built from a data url, so a room only renders this one when it is muted.
// so which bell it is can't be read out of the class list, but a room only renders one when it is
// muted.
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50") 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 // The only other absolutely-positioned dot in that button is the admin action-items one, at opacity-0.
// 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") const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50")
// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its // The name comes from nip-11; until that document lands the tooltip is the relay's host instead.
// 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) => const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`) page.locator(`.primary-nav [data-tip^="${name}"]`)
// The phone's bottom bar opens the space menu in a drawer; the desktop rail has no equivalent, // The desktop rail has no equivalent, since the menu is always on screen there.
// since the menu is always on screen there.
const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"}) const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"})
// The space menu's header, the one button in the secondary nav carrying the relay's address. // The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) => const spaceMenu = (page: Page, url: string) =>
page.locator(".secondary-nav").getByRole("button", {name: pathPattern(displayRelayUrl(url))}) page.locator(".secondary-nav").getByRole("button", {name: pathPattern(displayRelayUrl(url))})
// The menu it opens closes itself on the next mouseup anywhere, and a click whose press and release // The menu closes on the next mouseup, which a click with no gap between press and release reaches.
// land in the same instant can reach that listener as it mounts, shutting the menu again. A human's
// click has a gap between the two; playwright's has one only when it is asked for.
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200}) const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
// Every story here goes on to assert something that depends on the message having landed, so // Every story here depends on the message having landed, so posting is finished once it renders.
// posting is only finished once it has rendered.
const post = async (page: Page, content: string) => { const post = async (page: Page, content: string) => {
await send(page, content) await send(page, content)
await expect(message(page, content)).toBeVisible() await expect(message(page, content)).toBeVisible()
} }
// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, // RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, edit, menu.
// edit (only on your own recent message), menu.
const replyToMessage = (page: Page, text: string) => messageActions(page, text).nth(2).click() const replyToMessage = (page: Page, text: string) => messageActions(page, text).nth(2).click()
// Chromium's own notifications are invisible to a test, and a tab playwright drives is never // Chromium's own notifications are invisible to a test, and a tab playwright drives is never hidden.
// hidden, so both of the things the adapter reads are stubbed on the page. It takes the global at
// notify time, which is what lets this land after boot.
const captureNotifications = async (page: Page) => { const captureNotifications = async (page: Page) => {
const notifications: {title: string; body: string}[] = [] const notifications: {title: string; body: string}[] = []
@ -101,17 +87,14 @@ const captureNotifications = async (page: Page) => {
return notifications return notifications
} }
// The page bar names the room, so waiting for it is what says the composer below now belongs to // The page bar names the room, so waiting for it says the composer belongs to the room just opened.
// the room that was just opened rather than to the one being torn down.
const postTo = async (page: Page, room: string, content: string) => { const postTo = async (page: Page, room: string, content: string) => {
await roomLink(page, room).click() await roomLink(page, room).click()
await expect(pageBar(page)).toContainText(room) await expect(pageBar(page)).toContainText(room)
await post(page, content) await post(page, content)
} }
// Outbox routing resolves everything about a person through their relay list: settings are // Settings are published to the relays a person's list names, and a gift wrap needs one too.
// 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) => { const seedRelays = (space: SeededSpace, user: TestUser) => {
space.relayList(user) space.relayList(user)
space.messagingRelayList(user) space.messagingRelayList(user)
@ -126,8 +109,7 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => {
space.join(user.alice, "general", "random") space.join(user.alice, "general", "random")
space.join(user.bob, "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 // History bob wrote himself, since his own messages never raise an indicator.
// for him — his own messages never raise an indicator.
space.message(user.bob, "general", "anyone around?", at(2, HOUR)) space.message(user.bob, "general", "anyone around?", at(2, HOUR))
}) })
@ -145,15 +127,13 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => {
// Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation // Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation
await expect(unreadDot(navItem)).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 // A space's room list only exists in its own menu, so this is the one indicator the rail can't show.
// exists in its own menu, so this is the one indicator the rail can't show.
await navItem.click() await navItem.click()
const general = roomLink(bob, "General") const general = roomLink(bob, "General")
const random = roomLink(bob, "Random") 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 // Both rooms are on screen first, so a room with no dot is one raising none rather than not rendered.
// row that never rendered
await expect(random).toBeVisible() await expect(random).toBeVisible()
await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toHaveCount(0) await expect(unreadDot(random)).toHaveCount(0)
@ -189,8 +169,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
const space = scenario.space("space") const space = scenario.space("space")
// Silencing a whole space is only offered once push notifications are on, so that is where alice // Silencing a whole space is only offered once push notifications are on.
// has to start.
const alice = await as(users.alice, "/settings/alerts", { const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]}, context: {permissions: ["notifications"]},
}) })
@ -209,8 +188,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general"))) await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
// Silence this one room from its detail panel. Mute is the stronger of the two settings there: it // Mute is the stronger of the two settings there: it forces notifications off and hides badges.
// forces the room's notifications off and hides its unread badges too.
await openRoomDetail(alice) await openRoomDetail(alice)
const roomMute = settingRow(alice, "Mute").getByRole("checkbox") const roomMute = settingRow(alice, "Mute").getByRole("checkbox")
@ -241,44 +219,12 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
await expect(message(alice, "deploy is broken")).toBeVisible() await expect(message(alice, "deploy is broken")).toBeVisible()
// Turning the room back on restores its indicator for what comes next
await openRoomDetail(alice)
await roomMute.uncheck()
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, which is what the space-level setting is tested against
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible()
const menu = spaceMenu(alice, space.url) const menu = spaceMenu(alice, space.url)
await openSpaceMenu(menu) await openSpaceMenu(menu)
await alice.getByRole("button", {name: "Turn off notifications"}).click() await alice.getByRole("button", {name: "Turn off notifications"}).click()
// Silencing the space is about its alerts — hiding unread badges is the room mute's job — so the
// bell appears beside its name and the dots the rooms are carrying stay up.
await expect(mutedSpaceBell(menu)).toBeVisible() await expect(mutedSpaceBell(menu)).toBeVisible()
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible()
// Reopening the menu shows the label the mute flipped, and turning it back on clears the bell.
// Clicking the header while the menu is still on its way out toggles it straight back shut, so
// wait for it to go before reopening it.
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
await expect(turnOn).toHaveCount(0)
await openSpaceMenu(menu)
await expect(turnOn).toBeVisible()
await turnOn.click()
await expect(mutedSpaceBell(menu)).toHaveCount(0)
}) })
test("US-105 land on the home page", async ({seed, as}) => { test("US-105 land on the home page", async ({seed, as}) => {
@ -299,27 +245,13 @@ test("US-105 land on the home page", async ({seed, as}) => {
// With none configured, /home is the dashboard, whose empty inbox offers two ways out // With none configured, /home is the dashboard, whose empty inbox offers two ways out
const page = await as(users.alice, "/home") const page = await as(users.alice, "/home")
const addSpace = page.getByRole("link", {name: "Add a space"}) await page.getByRole("link", {name: "Add a space"}).click()
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 expect(page).toHaveURL(/\/spaces$/)
await page.goBack()
await expect(page).toHaveURL(/\/home$/)
await startConversation.click()
await expect(page).toHaveURL(/\/chat$/)
}) })
test("US-116 read the home dashboard", async ({seed, as}) => { test("US-116 read the home dashboard", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -341,7 +273,6 @@ test("US-116 read the home dashboard", async ({seed, as}) => {
) )
}) })
const space = scenario.space("space")
const page = await as(users.alice, "/home") const page = await as(users.alice, "/home")
// Each conversation is one link, headed by its room and carrying the latest message underneath // Each conversation is one link, headed by its room and carrying the latest message underneath
@ -351,21 +282,10 @@ test("US-116 read the home dashboard", async ({seed, as}) => {
await expect(conversation).toContainText("General") await expect(conversation).toContainText("General")
await expect(unreadDot(conversation)).toBeVisible() await expect(unreadDot(conversation)).toBeVisible()
// A space's threads, events and classifieds are counted per space in Activity rather than listed // Threads, events and classifieds are counted per space in Activity rather than listed as conversations.
// as conversations, so the inbox stays a list of messages
const activity = page.getByRole("link").filter({hasText: "1 thread"}) const activity = page.getByRole("link").filter({hasText: "1 thread"})
await expect(page.getByRole("heading", {name: "Activity"})).toBeVisible()
await expect(activity).toBeVisible() await expect(activity).toBeVisible()
await expect(conversation).not.toContainText("1 thread")
// Relay health checks had no mount point at all before the dashboard
await expect(page.getByText("Health checks")).toBeVisible()
// Hosting shows even though alice hosts nothing, as an invitation to start a space
await expect(page.getByRole("button", {name: "Start a space"})).toBeVisible()
await expect(conversation).toHaveAttribute("href", roomPath(space.url, "general"))
// The inbox is the badges, so a conversation leaves the list once it has nothing unread // The inbox is the badges, so a conversation leaves the list once it has nothing unread
await page.getByRole("button", {name: "Mark all read"}).click() await page.getByRole("button", {name: "Mark all read"}).click()
@ -377,7 +297,6 @@ test("US-116 read the home dashboard", async ({seed, as}) => {
test("US-117 read the network feed on home", async ({seed, as}) => { test("US-117 read the network feed on home", async ({seed, as}) => {
const note = "the tide charts are wrong again" const note = "the tide charts are wrong again"
const quiet = "the ferry is running on time"
const reply = "they were reprinted last week" const reply = "they were reprinted last week"
const topic = "Dredging the channel" const topic = "Dredging the channel"
@ -388,8 +307,7 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// The feed asks each follow's write relays for their notes, so a note is only reachable // The feed asks each follow's write relays for their notes, so a note needs a relay list naming one.
// through a relay list naming one.
seedRelays(space, user.alice) seedRelays(space, user.alice)
seedRelays(space, user.bob) seedRelays(space, user.bob)
@ -401,8 +319,6 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
space.kind(Note).writer().setContent(note).renderTemplate(), space.kind(Note).writer().setContent(note).renderTemplate(),
) )
space.event(user.bob, () => space.kind(Note).writer().setContent(quiet).renderTemplate())
space.event(user.bob, () => space.event(user.bob, () =>
space.kind(Note).writer().setParent(posted.event).setContent(reply).renderTemplate(), space.kind(Note).writer().setParent(posted.event).setContent(reply).renderTemplate(),
) )
@ -427,29 +343,19 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Network"})).toBeVisible() await expect(page.getByRole("heading", {name: "Network"})).toBeVisible()
await expect(page.getByText(note)).toBeVisible() await expect(page.getByText(note)).toBeVisible()
// The feed is notes only: a reply is counted on the note it answers rather than drawn // The feed is notes only: a reply is counted on the note it answers and gets no card of its own.
// underneath it, and it never gets a card of its own.
await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
await expect(page.getByText(reply)).toHaveCount(0) await expect(page.getByText(reply)).toHaveCount(0)
// The count is there whether or not anybody replied, so every post reads the same.
await expect(page.getByText(quiet)).toBeVisible()
await expect(
network.locator(".card").filter({hasText: quiet}).getByRole("button", {name: "0 replies"}),
).toBeVisible()
// The feed carries every kind of post a follow writes, not only their notes. // The feed carries every kind of post a follow writes, not only their notes.
await expect(network.getByText(topic)).toBeVisible() await expect(network.getByText(topic)).toBeVisible()
// Every card says when it was posted.
await expect(network.getByRole("button", {name: /\d+\/\d+\/\d+/}).first()).toBeVisible()
}) })
test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => { test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => {
const note = "the lighthouse has been dark since tuesday" const note = "the lighthouse has been dark since tuesday"
const answer = "the keeper is on holiday" const answer = "the keeper is on holiday"
const scenario = await seed(({relay, open, user, at}) => { await seed(({relay, open, user, at}) => {
const space = relay("space") const space = relay("space")
const indexer = open("indexer") const indexer = open("indexer")
const outbox = open("outbox") const outbox = open("outbox")
@ -457,18 +363,14 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) =>
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere // Bob's relay is somewhere she writes and nowhere she reads, which is enough to identify to it.
// she reads, which is enough for her client to identify to it -- zooid answers no REQ without
// nip-42, and Flotilla only identifies to relays her own lists name -- while leaving it out of
// the read urls a feed's context is asked of.
indexer.relayList(user.alice, { indexer.relayList(user.alice, {
read: [space.url, indexer.url], read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url], write: [space.url, indexer.url, outbox.url],
}) })
indexer.follows(user.alice, [user.bob]) indexer.follows(user.alice, [user.bob])
// Bob is in none of her spaces and writes nowhere she reads, so his relay list is the only // Bob is in none of her spaces, so his relay list is the only thing that can point the feed at him.
// thing that can point the feed at his notes.
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]}) indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
outbox.profile(user.bob, {name: "Bob Barker"}) outbox.profile(user.bob, {name: "Bob Barker"})
@ -481,7 +383,6 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) =>
) )
}) })
const space = scenario.space("space")
const page = await as(users.alice, "/home") const page = await as(users.alice, "/home")
const network = page const network = page
@ -491,25 +392,12 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) =>
await expect(network.getByText(note)).toBeVisible() await expect(network.getByText(note)).toBeVisible()
await expect(network.getByText("Bob Barker")).toBeVisible() await expect(network.getByText("Bob Barker")).toBeVisible()
// The reply is on bob's relay too, and alice reads from her space rather than from that relay, // Alice reads from her space rather than bob's relay, so a context asked of hers comes back empty.
// so a context asked of her own read relays comes back with nothing.
await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
// Her space never held any of it.
const fromSpace = getTranscript(page.context()).filter(
({url, direction, message}) =>
url === space.url &&
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
[note, answer].includes(message[2].content),
)
expect(fromSpace).toEqual([])
}) })
test("US-117 read the network feed when one relay never answers", async ({seed, as}) => { test("US-117 read the network feed when one relay never answers", async ({seed, as}) => {
// Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and // Nothing serves this url: the fault is a relay that takes the socket and then says nothing.
// then says nothing, which is all the spec asks of it.
const stalled = "wss://stalled.test/" const stalled = "wss://stalled.test/"
const note = "the drawbridge has been stuck open since noon" const note = "the drawbridge has been stuck open since noon"
@ -521,9 +409,7 @@ test("US-117 read the network feed when one relay never answers", async ({seed,
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// The relays the feed will ask are somewhere she writes and nowhere she reads, which is what // Somewhere she writes and nowhere she reads, which is what lets her client identify to them.
// lets her client identify to them: zooid answers no REQ without nip-42, and Flotilla only
// identifies to relays her own lists name.
indexer.relayList(user.alice, { indexer.relayList(user.alice, {
read: [space.url, indexer.url], read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url, stalled], write: [space.url, indexer.url, outbox.url, stalled],
@ -542,12 +428,59 @@ test("US-117 read the network feed when one relay never answers", async ({seed,
.locator("section") .locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})}) .filter({has: page.getByRole("heading", {name: "Network"})})
// A span releases the events it found once it is done waiting, so a span that waits on every // A span releases what it found once it is done waiting, so one silent relay holds it empty.
// relay it asked is a span one silent relay holds empty.
await expect(network.getByText(note)).toBeVisible({timeout: 20_000}) await expect(network.getByText(note)).toBeVisible({timeout: 20_000})
await expect(network.getByText("Bob Barker")).toBeVisible() await expect(network.getByText("Bob Barker")).toBeVisible()
}) })
// A url the container does not serve, so its nip-11 document never arrives and nothing knows what it supports.
const unreachableRelayUrl = "wss://unreachable.test/"
test("US-133 keep the search relays that support search", async ({seed, as}) => {
const scenario = await seed(({relay, open, user}) => {
const space = relay("space")
const indexer = open("indexer")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
indexer.relayList(user.alice, {
read: [space.url, indexer.url],
write: [space.url, indexer.url],
})
// No search relay of hers is a space, so the health check is the only thing asking for their metadata.
indexer.event(user.alice, () =>
indexer
.kind(SearchRelayList)
.writer()
.setUrls([indexer.url, outbox.url, unreachableRelayUrl])
.renderTemplate(),
)
})
const indexer = scenario.open("indexer")
const outbox = scenario.open("outbox")
const page = await as(users.alice, "/home", {
relayInfo: {[indexer.url]: {supported_nips: [1, 50]}},
})
const healthCheck = page.getByRole("group", {name: "Invalid Search Relays"})
await expect(healthCheck).toBeVisible()
await healthCheck.getByRole("button", {name: "Fix"}).click()
const review = dialog(page, "Review changes")
// Outbox answered and advertised no search; the other two are a relay that does and one nobody can ask.
await expect(review.getByText(displayRelayUrl(outbox.url))).toBeVisible()
await expect(review.getByText(displayRelayUrl(indexer.url))).toHaveCount(0)
await expect(review.getByText(displayRelayUrl(unreachableRelayUrl))).toHaveCount(0)
})
test("US-106 share text into the app", async ({seed, as}) => { test("US-106 share text into the app", async ({seed, as}) => {
const shared = "the offsite is moving to the 14th" const shared = "the offsite is moving to the 14th"
@ -555,9 +488,8 @@ test("US-106 share text into the app", async ({seed, as}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.room("watercooler", {name: "Watercooler"}) space.join(user.alice, "general")
space.join(user.alice, "general", "watercooler") space.join(user.bob, "general")
space.join(user.bob, "general", "watercooler")
space.profile(user.bob, {name: "Bob Barker"}) space.profile(user.bob, {name: "Bob Barker"})
seedRelays(space, user.alice) seedRelays(space, user.alice)
@ -568,32 +500,18 @@ test("US-106 share text into the app", async ({seed, as}) => {
const space = scenario.space("space") const space = scenario.space("space")
// A client only unwraps direct messages once its owner has opened chat, so a conversation the // A client only unwraps direct messages once its owner has opened chat.
// share dialog can offer is one alice has already seen.
const page = await as(users.alice, "/chat") const page = await as(users.alice, "/chat")
await expect(page.getByText("are you around?").first()).toBeVisible() 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 // The text arrives in the query string rather than through a native share intent.
// same thing src/routes/share reads either way.
await page.goto(`/share?text=${encodeURIComponent(shared)}`) await page.goto(`/share?text=${encodeURIComponent(shared)}`)
const share = dialog(page, "Share") 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() 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: "General"}).click()
await share.getByRole("button", {name: "Share", exact: true}).click() await share.getByRole("button", {name: "Share", exact: true}).click()
@ -631,8 +549,7 @@ test("US-107 open a nostr link", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible() 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 // EventInfo renders the link on mount rather than during setup.
// copying an empty field
const eventLink = settingRow(page, "Event Link") const eventLink = settingRow(page, "Event Link")
await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/) await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/)
@ -681,8 +598,7 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
const space = scenario.space("space") const space = scenario.space("space")
const other = scenario.space("other") const other = scenario.space("other")
// A phone has no room list on screen while a room is open, so the bottom bar is the only place // A phone has no room list on screen while a room is open.
// activity elsewhere can surface
const bob = await as(users.bob, roomPath(space.url, "general"), { const bob = await as(users.bob, roomPath(space.url, "general"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true}, context: {viewport: {width: 390, height: 844}, hasTouch: true},
}) })
@ -707,35 +623,17 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
await expect(message(bob, "the server is on fire")).toBeVisible() await expect(message(bob, "the server is on fire")).toBeVisible()
// Reading the other space empties the bar, even though bob's own space still has an unread room // Bob's own space still has an unread room, which is that space's own business.
// — that one is the space's business, and its own indicators carry it
await bob.goto(spacePath(space.url)) await bob.goto(spacePath(space.url))
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
await expect(unreadDot(menuButton)).toHaveCount(0) await expect(unreadDot(menuButton)).toHaveCount(0)
}) })
// SpaceMenuNavItems offers a content type once the space has an event of that kind or something // SpaceMenuNavItems offers a content type once the space has an event of that kind or an unread.
// under it is unread, so this link appearing at all is what says the seeded content loaded.
const contentNavItem = (page: Page, name: string) => const contentNavItem = (page: Page, name: string) =>
page.locator(".secondary-nav").getByRole("link", {name}) page.locator(".secondary-nav").getByRole("link", {name})
const seedClassified = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
space.event(
user,
() =>
space
.kind(Classified)
.writer()
.setRoom(space.url, "general")
.setIdentifier()
.setTitle(title)
.setPrice(100)
.setContent("in good condition")
.renderTemplate(),
createdAt,
)
const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) => const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
space.event( space.event(
user, user,
@ -780,9 +678,7 @@ test("US-112 see which threads are unread", async ({seed, as}) => {
await expect(his).toBeVisible() await expect(his).toBeVisible()
// syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to // syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
// a second, so a dot the list is about to clear stays up well past the click. Nothing on screen
// reports the tick — the nav dot goes down on the route change either way — so wait it out.
await bob.waitForTimeout(1500) await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible() await expect(unreadDot(hers)).toBeVisible()
@ -806,74 +702,64 @@ test("US-112 see which threads are unread", async ({seed, as}) => {
await expect(unreadDot(threadsNav)).toHaveCount(0) await expect(unreadDot(threadsNav)).toHaveCount(0)
}) })
test("US-113 see which threads are unread on a phone", async ({seed, as}) => { test("US-137 reach a thread from the inbox activity its comment raised", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.message(user.bob, "general", "the server is on fire", at(3, HOUR))
seedThread(space, user.bob, "where is the sextant", at(3, HOUR)) const topic = seedThread(space, user.bob, "where is the sextant", at(2, HOUR))
seedThread(space, user.alice, "the server is on fire", at(2, HOUR))
space.event(
user.bob,
() =>
space
.kind(Comment)
.writer()
// publishComment carries the root's room forward, so a comment on a thread is tagged h too.
.setRoom(space.url, "general")
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent("starboard locker, under the charts")
.renderTemplate(),
at(1, HOUR),
)
}) })
const space = scenario.space("space") const space = scenario.space("space")
const alice = await as(users.alice, "/home")
// ThreadBoard swaps its table for a list of links when the board is too narrow for the table, // The room's row is its own chat, whatever the newest thing tagged for the room happens to be
// and the two branches render the thread separately, so a dot on one says nothing about the const conversation = alice.getByRole("link").filter({hasText: "the server is on fire"})
// other.
const bob = await as(users.bob, `${spacePath(space.url)}/threads`, {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const hers = bob.getByRole("link").filter({hasText: "the server is on fire"}) await expect(conversation).toBeVisible()
const his = bob.getByRole("link").filter({hasText: "where is the sextant"}) await expect(conversation).toContainText("General")
await expect(alice.getByRole("link").filter({hasText: "starboard locker"})).toHaveCount(0)
await expect(his).toBeVisible() // The comment is activity on the thread, so the space's thread count is what it raises
await expect(alice.getByRole("link").filter({hasText: "1 thread"})).toBeVisible()
// Same tick and throttle as US-112: a dot read before both have run is one the list may still be await conversation.click()
// about to clear.
await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible() await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
await expect(unreadDot(his)).toHaveCount(0)
})
// Classifieds stands in for the five boards whose items are cards rather than rows — they all await contentNavItem(alice, "Threads").click()
// render the same UnreadDot off the same content path, and only the corner it sits in differs.
test("US-114 see which listings are unread", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"}) const topic = alice.getByRole("row").filter({hasText: "where is the sextant"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedClassified(space, user.bob, "vintage sextant", at(3, HOUR)) await expect(topic).toBeVisible()
seedClassified(space, user.alice, "brass astrolabe", at(2, HOUR))
})
const space = scenario.space("space") // syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
const bob = await as(users.bob, roomPath(space.url, "general")) await alice.waitForTimeout(1500)
const classifiedsNav = contentNavItem(bob, "Classifieds") await expect(unreadDot(topic)).toBeVisible()
await expect(classifiedsNav).toBeVisible() await topic.click()
await expect(unreadDot(classifiedsNav)).toBeVisible()
await classifiedsNav.click() await expect(alice.getByText("starboard locker, under the charts")).toBeVisible()
const hers = bob.getByRole("link").filter({hasText: "brass astrolabe"})
const his = bob.getByRole("link").filter({hasText: "vintage sextant"})
await expect(his).toBeVisible()
// Same tick and throttle as US-112.
await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible()
await expect(unreadDot(his)).toHaveCount(0)
}) })
const seedPoll = (space: SeededSpace, user: TestUser, title: string, createdAt: number) => const seedPoll = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
@ -903,8 +789,7 @@ test("US-123 find a section whose newest item is older than the sync window", as
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// Older than the month the space sync asks for, so the nav can only know about it by asking // Older than the month the space sync asks for, so the nav can only know by asking the relay.
// the relay what the space holds rather than by reading what has loaded.
seedPoll(space, user.alice, "which sextant should we buy", at(60, DAY)) seedPoll(space, user.alice, "which sextant should we buy", at(60, DAY))
}) })
@ -931,8 +816,7 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se
space.join(user.bob, "general") space.join(user.bob, "general")
other.join(user.bob, "general") other.join(user.bob, "general")
// A comment on a poll nothing holds — the relay kept the comment and dropped its subject. // A comment on a poll nothing holds: the relay kept the comment and dropped its subject.
// It counts toward the space either way, so the section it belongs to has to be reachable.
space.event( space.event(
user.alice, user.alice,
() => () =>
@ -949,7 +833,6 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se
const space = scenario.space("space") const space = scenario.space("space")
const other = scenario.space("other") const other = scenario.space("other")
// A space hides its own dot while you are in it, so the badge is only readable from elsewhere
const bob = await as(users.bob, roomPath(other.url, "general")) const bob = await as(users.bob, roomPath(other.url, "general"))
const spaceButton = spaceNavItem(bob, space.name) const spaceButton = spaceNavItem(bob, space.name)
@ -966,13 +849,39 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se
await expect(bob.getByText("No polls found.")).toBeVisible() await expect(bob.getByText("No polls found.")).toBeVisible()
// Reading it is the end of it: with nothing unread and no poll to list, the space stops // With nothing unread and no poll to list, the space stops offering the section at all.
// offering the section at all
await roomLink(bob, "General").click() await roomLink(bob, "General").click()
await expect(pollsNav).toHaveCount(0) await expect(pollsNav).toHaveCount(0)
}) })
test("US-132 see a room whose newest message is old", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
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")
other.join(user.bob, "general")
space.message(user.alice, "general", "still nobody bought a sextant", at(60, DAY))
})
const space = scenario.space("space")
const other = scenario.space("other")
const bob = await as(users.bob, roomPath(other.url, "general"))
const spaceButton = spaceNavItem(bob, space.name)
await expect(unreadDot(spaceButton)).toBeVisible()
await spaceButton.click()
await expect(unreadDot(roomLink(bob, "General"))).toBeVisible()
})
test("US-120 read what a notification says", async ({seed, as}) => { test("US-120 read what a notification says", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -987,8 +896,7 @@ test("US-120 read what a notification says", async ({seed, as}) => {
const space = scenario.space("space") const space = scenario.space("space")
// Push notifications are off until they are asked for, and the tab has to be in the background // The tab has to be in the background before a push notification is raised at all.
// before one is raised at all.
const alice = await as(users.alice, "/settings/alerts", { const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]}, context: {permissions: ["notifications"]},
}) })
@ -1014,9 +922,7 @@ test("US-120 read what a notification says", async ({seed, as}) => {
await expect(message(bob, "sunday, the notice is at")).toBeVisible() await expect(message(bob, "sunday, the notice is at")).toBeVisible()
// A reply prepends the message it answers, so its first line is an entity and says nothing about // A reply prepends the message it answers, so its first line is an entity rather than the reply.
// the reply. The preview is the words bob wrote, with the url named by its host rather than
// spelled out, and the quote of alice's message tags her, so it reads as a mention.
await expect await expect
.poll(() => notifications.at(-1)) .poll(() => notifications.at(-1))
.toEqual({ .toEqual({
@ -1024,3 +930,59 @@ test("US-120 read what a notification says", async ({seed, as}) => {
body: "sunday, the notice is at a link to harbor.example", body: "sunday, the notice is at a link to harbor.example",
}) })
}) })
test("US-136 hear about a new message and not about a new thread", 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 space = scenario.space("space")
// The tab has to be in the background before a notification is raised at all.
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()
await roomLink(alice, "General").click()
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
const notifications = await captureNotifications(alice)
const bob = await as(users.bob, `${spacePath(space.url)}/threads`)
const board = bob
.locator("section")
.filter({has: bob.getByRole("heading", {name: "General", exact: true})})
await board.getByRole("button", {name: "Create", exact: true}).click()
const threadForm = modalForm(bob, "Create a Thread")
await threadForm.getByPlaceholder("What is this thread about?").fill("the dock is closing")
await noteEditor(threadForm).pressSequentially("sunday, apparently")
await threadForm.getByRole("button", {name: "Create Thread"}).click()
await expect(board.getByRole("row").filter({hasText: "the dock is closing"})).toBeVisible()
// Alice holds the thread before bob says anything, so a notification for it would be here by now.
await contentNavItem(alice, "Threads").click()
await expect(alice.getByRole("row").filter({hasText: "the dock is closing"})).toBeVisible()
await postTo(bob, "General", "when does the dock close?")
await expect
.poll(() => notifications)
.toEqual([{title: "New message", body: "when does the dock close?"}])
})

View file

@ -3,7 +3,7 @@ import type {Page} from "@playwright/test"
import {npubEncode, nsecEncode} from "nostr-tools/nip19" import {npubEncode, nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib" import {hexToBytes} from "@welshman/lib"
import {expect, roomPath, spacePath, test, users} from "../harness" import {expect, roomPath, test, users} from "../harness"
import type {TestUser} from "../harness" import type {TestUser} from "../harness"
// The sign-in gate AppContainer renders in place of the app for anyone without a session. // The sign-in gate AppContainer renders in place of the app for anyone without a session.
@ -11,8 +11,7 @@ const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotil
const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret)) 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 // The only way to watch a session come into existence: one `as()` injects is re-applied on every navigation.
// session come into existence: a session injected by `as()` is re-applied on every navigation.
const logInWithKey = async (page: Page, key: string) => { const logInWithKey = async (page: Page, key: string) => {
await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Key"}).click() await page.getByRole("button", {name: "Log in with Key"}).click()
@ -21,14 +20,11 @@ const logInWithKey = async (page: Page, key: string) => {
await page.locator("form").getByRole("button", {name: "Log in", exact: true}).click() await page.locator("form").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 // The nav's settings link carries its label as a tooltip, so it is addressed by where it goes.
// icon is a masked svg with no alt text — so it is addressed by where it goes.
const openSettings = (page: Page) => const openSettings = (page: Page) =>
page.locator('.primary-nav a[href="/settings/profile"]').click() page.locator('.primary-nav a[href="/settings/profile"]').click()
// The profile page's Public Key field. A nip01 login also renders a masked Private Key input right // A nip01 login renders a masked Private Key too, so the npub is the readonly non-password one.
// below it, so `getByRole("textbox")` on this page is two elements — the npub is the readonly one
// that isn't a password.
const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])') const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])')
test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => {
@ -38,41 +34,15 @@ test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
// A room url rather than the root: the gate stands in front of the whole app, so where the // A room url rather than the root: the gate stands in front of the whole app.
// visitor landed makes no difference.
const page = await visit(roomPath(url, "general")) const page = await visit(roomPath(url, "general"))
await expect(gate(page)).toBeVisible() await expect(gate(page)).toBeVisible()
const signInGate = page.getByRole("dialog", {name: "Welcome to Flotilla!"})
const logIn = signInGate.getByRole("button", {name: "Log in"})
await expect(signInGate).toBeVisible()
await expect(logIn).toBeFocused()
await expect(signInGate.getByRole("button", {name: "Close dialog"})).toHaveCount(0)
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 page.keyboard.press("Escape")
await expect(gate(page)).toBeVisible() await expect(gate(page)).toBeVisible()
for (const key of ["Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
await page.keyboard.press(key)
expect(await signInGate.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true)
}
await page.locator(".dialog-overlay > button").click({position: {x: 4, y: 4}})
await expect(gate(page)).toBeVisible()
await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in"}).click()
await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible() await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible()
@ -87,17 +57,16 @@ test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => {
}) })
test("US-002 sign up by generating a new key", async ({seed, visit}) => { test("US-002 sign up by generating a new key", async ({seed, visit}) => {
const scenario = await seed(({relay}) => { await seed(({relay}) => {
relay("space").room("general", {name: "General"}) relay("space").room("general", {name: "General"})
}) })
const {url} = scenario.space("space")
const page = await visit() const page = await visit()
await page.getByRole("button", {name: "Create an account"}).click() await page.getByRole("button", {name: "Create an account"}).click()
await page.getByRole("button", {name: "Generate a key"}).click() await page.getByRole("button", {name: "Generate a key"}).click()
await page.locator('input[type="text"]').fill("Nova Tester") await page.getByRole("textbox", {name: "Nickname"}).fill("Nova Tester")
await page.getByRole("button", {name: "Create Account"}).click() await page.getByRole("button", {name: "Create Account"}).click()
await expect(page.getByRole("heading", {name: "Backup your Key"})).toHaveCount(0) await expect(page.getByRole("heading", {name: "Backup your Key"})).toHaveCount(0)
@ -106,65 +75,22 @@ test("US-002 sign up by generating a new key", async ({seed, visit}) => {
await page.getByRole("button", {name: "Go to Dashboard"}).click() await page.getByRole("button", {name: "Go to Dashboard"}).click()
await expect(page).toHaveURL(/\/home$/) 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)
await expect(page.getByText("Back Up Your Key")).toBeVisible()
// 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 openSettings(page)
await expect(page.getByText("Nova Tester")).toBeVisible() await expect(page.getByText("Nova Tester")).toBeVisible()
await page.goto("/home") await page.goto("/home")
await expect(page.getByText("Back Up Your Key")).toBeVisible()
await page.reload() await page
.getByRole("group", {name: "Back Up Your Key"})
await expect(page.getByText("Back Up Your Key")).toBeVisible() .getByRole("button", {name: "Fix"})
.click()
const backupCheck = page.getByRole("group", {name: "Back Up Your Key"})
await page.getByRole("button", {name: "Apply all recommendations"}).click()
await expect(page.getByRole("heading", {name: "Review changes"})).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
await expect(backupCheck).toBeVisible()
await page.getByRole("button", {name: "Apply all recommendations"}).click()
await page.getByRole("button", {name: "Confirm"}).click()
await expect(page.getByRole("heading", {name: "Review changes"})).toHaveCount(0)
await expect(page.getByRole("heading", {name: "Backup your Key"})).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
await expect(backupCheck).toBeVisible()
await backupCheck.getByRole("button", {name: "Fix"}).click()
await expect(page.getByRole("heading", {name: "Backup your Key"})).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
await expect(page.getByText("Back Up Your Key")).toBeVisible()
await backupCheck.getByRole("button", {name: "Fix"}).click()
const doneButton = page.getByRole("button", {name: "Done"}) const doneButton = page.getByRole("button", {name: "Done"})
const password = page.locator('input[type="password"]')
await page.getByRole("button", {name: "I want to download an encrypted version"}).click() await page.getByRole("button", {name: "I want to download an encrypted version"}).click()
await password.fill("hunter2") await page.locator('input[type="password"]').fill("correct horse battery staple")
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(doneButton).toBeDisabled()
await password.fill("correct horse battery staple")
const download = page.waitForEvent("download") const download = page.waitForEvent("download")
@ -196,55 +122,29 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => {
const withNsec = await visit() const withNsec = await visit()
await logInWithKey(withNsec, nsecFor(users.alice)) await logInWithKey(withNsec, nsecFor(users.alice))
await expect(withNsec.locator(".primary-nav")).toBeVisible()
await expect(withNsec.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withNsec) await openSettings(withNsec)
await expect(npubField(withNsec)).toHaveValue(aliceNpub) await expect(npubField(withNsec)).toHaveValue(aliceNpub)
const withHex = await visit()
await logInWithKey(withHex, users.alice.secret)
await expect(withHex.locator(".primary-nav")).toBeVisible()
await expect(withHex.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withHex)
await expect(npubField(withHex)).toHaveValue(aliceNpub)
const withNcryptsec = await visit() const withNcryptsec = await visit()
await withNcryptsec.getByRole("button", {name: "Log in"}).click() await withNcryptsec.getByRole("button", {name: "Log in"}).click()
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click() await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
const key = withNcryptsec.getByPlaceholder("nsec1...")
const submit = withNcryptsec.locator("form").getByRole("button", {name: "Log in", exact: true}) const submit = withNcryptsec.locator("form").getByRole("button", {name: "Log in", exact: true})
await key.fill("this is not a key") await withNcryptsec.getByPlaceholder("nsec1...").fill(ncryptsec)
await expect(submit).toBeDisabled()
await key.fill(ncryptsec)
await withNcryptsec.getByPlaceholder("Your password").fill("not the password") await withNcryptsec.getByPlaceholder("Your password").fill("not the password")
await submit.click() await submit.click()
await expect(withNcryptsec.getByRole("alert")).toContainText( await expect(withNcryptsec.getByRole("alert")).toContainText(
"Failed to decrypt key. Please check your password.", "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 withNcryptsec.getByPlaceholder("Your password").fill(password)
await submit.click() await submit.click()
await expect(withNcryptsec.locator(".primary-nav")).toBeVisible()
await expect(withNcryptsec.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withNcryptsec) await openSettings(withNcryptsec)
await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) await expect(npubField(withNcryptsec)).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(npubField(asBob)).toHaveValue(npubEncode(users.bob.pubkey))
}) })
test("US-004 log in with a browser extension", async ({seed, visit}) => { test("US-004 log in with a browser extension", async ({seed, visit}) => {
@ -262,7 +162,6 @@ test("US-004 log in with a browser extension", async ({seed, visit}) => {
await openSettings(page) await openSettings(page)
await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey)) await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey))
await expect(page.locator('input[type="password"]')).toHaveCount(0)
const withoutExtension = await visit() const withoutExtension = await visit()
@ -284,32 +183,17 @@ test("US-005 log in with a remote signer", async ({seed, visit}) => {
await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Remote Signer"}).click() await page.getByRole("button", {name: "Log in with Remote Signer"}).click()
const bunker = page.getByPlaceholder("bunker://") // The relay named here belongs to no scenario, so a connection attempt is recorded as a leak.
const next = page.getByRole("button", {name: "Next"}) await page
.getByPlaceholder("bunker://")
.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/")
await page.getByRole("button", {name: "Next"}).click()
// The relay named here belongs to no scenario, so a connection attempt would be recorded as a await expect(page.getByRole("alert")).toContainText("invalid bunker link")
// 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 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 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}) => { test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => {
@ -320,32 +204,16 @@ test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) =>
space.join(user.alice, "general") space.join(user.alice, "general")
}) })
const npub = npubEncode(users.alice.pubkey)
const page = await visit() const page = await visit()
await logInWithKey(page, nsecFor(users.alice)) await logInWithKey(page, nsecFor(users.alice))
await expect(page.locator(".primary-nav")).toBeVisible()
await openSettings(page) await openSettings(page)
await expect(npubField(page)).toHaveValue(npub) await expect(npubField(page)).toHaveValue(npubEncode(users.alice.pubkey))
await page.reload() await page.reload()
await expect(page.locator(".primary-nav")).toBeVisible() await expect(npubField(page)).toHaveValue(npubEncode(users.alice.pubkey))
await expect(gate(page)).toHaveCount(0)
await expect(npubField(page)).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(npubField(page)).toHaveValue(npub)
await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click() await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click()
await page.locator("form").getByRole("button", {name: "Log Out"}).click() await page.locator("form").getByRole("button", {name: "Log Out"}).click()
@ -353,7 +221,7 @@ test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) =>
await expect(gate(page)).toBeVisible() await expect(gate(page)).toBeVisible()
}) })
test("US-007 inspect your keys and signer status", async ({seed, as, visit}) => { test("US-007 inspect your keys and signer status", async ({seed, as}) => {
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
@ -371,7 +239,6 @@ test("US-007 inspect your keys and signer status", async ({seed, as, visit}) =>
const npubInput = npubField(page) const npubInput = npubField(page)
await expect(npubInput).toHaveValue(npub) await expect(npubInput).toHaveValue(npub)
await expect(npubInput).toHaveJSProperty("readOnly", true)
await page.locator("label.input").filter({has: npubInput}).getByRole("button").click() await page.locator("label.input").filter({has: npubInput}).getByRole("button").click()
@ -385,38 +252,7 @@ test("US-007 inspect your keys and signer status", async ({seed, as, visit}) =>
await expect.poll(readClipboard).toBe(nsecFor(users.alice)) await expect.poll(readClipboard).toBe(nsecFor(users.alice))
const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last() await expect(page.getByText("Logged in with private key")).toBeVisible()
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}) => { test("US-008 delete your nostr account", async ({seed, as, visit}) => {
@ -444,30 +280,19 @@ test("US-008 delete your nostr account", async ({seed, as, visit}) => {
await expect(confirm).toBeDisabled() await expect(confirm).toBeDisabled()
await phrase.fill("permanently delete my")
await expect(confirm).toBeDisabled()
await phrase.fill("permanently delete my nostr account") await phrase.fill("permanently delete my nostr account")
await expect(confirm).toBeEnabled() await expect(confirm).toBeEnabled()
await confirm.click() await confirm.click()
await expect(alice.getByRole("progressbar")).toBeVisible()
await expect(gate(alice)).toBeVisible() await expect(gate(alice)).toBeVisible()
const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`) const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`)
// zooid honours the kind-62 right-to-vanish, so alice's profile — including the "[deleted]" name // zooid honours the kind-62 right-to-vanish, so the profile is gone rather than renamed.
// the app blanks it to first — is gone rather than renamed. Bob sees the npub fallback the app
// shows for anyone with no profile: displayPubkey, which is npub.slice(0,8)+"…"+npub.slice(-5).
const aliceNpub = npubEncode(users.alice.pubkey) const aliceNpub = npubEncode(users.alice.pubkey)
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5) const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
await expect(bob.getByRole("heading", {name: fallback})).toBeVisible() await expect(bob.getByRole("heading", {name: fallback})).toBeVisible()
// Her relay list went with the account, so the feed has nowhere to ask and keeps looking rather
// than settling on its empty state. What the story is about is that nothing of hers comes back.
await expect(bob.locator(".card.card-interactive")).toHaveCount(0)
}) })

View file

@ -1,8 +1,8 @@
import {createHash} from "node:crypto" import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import {nprofileEncode, npubEncode} from "nostr-tools/nip19" import {npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE, MONTH} from "@welshman/lib" import {HOUR, MINUTE, MONTH, uniq} from "@welshman/lib"
import {NOTE, STATUS, makeEvent} from "@welshman/util" import {NOTE, PROFILE, STATUS, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util" import type {SignedEvent} from "@welshman/util"
import {FollowList, Note, PinList, Profile} from "@welshman/domain" import {FollowList, Note, PinList, Profile} from "@welshman/domain"
import { import {
@ -10,47 +10,37 @@ import {
GIF, GIF,
WEBP, WEBP,
expect, expect,
makeTestUser, getPublished,
getPublishedEvents,
mockBlossom, mockBlossom,
mockGeocoding,
noteEditor,
profilePath, profilePath,
spacePath, spacePath,
test, test,
topDialog, topDialog,
users, users,
} from "../harness" } from "../harness"
import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent} type Seeded = {readonly id: string; readonly event: SignedEvent}
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above // The Reputation and Spaces panels only exist above tailwind's xl breakpoint, where 1280 sits.
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
const DESKTOP = {viewport: {width: 1440, height: 900}} const DESKTOP = {viewport: {width: 1440, height: 900}}
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]} const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never // uploadFile appends the extension when the descriptor's url carries none, as the mock's never does.
// does.
const uploadedUrl = (body: Buffer, extension: string) => const uploadedUrl = (body: Buffer, extension: string) =>
`${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}` `${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
// displayPubkey in @welshman/domain: the npub with its middle taken out. // The page's own region, so an assertion about an avatar isn't satisfied by the copy the nav renders.
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") const pageContent = (page: Page) => page.locator(".page__content")
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in // ProfileTrust and ProfileSharedSpaces are rendered twice, stacked for narrow viewports and here.
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
const sidebar = (page: Page) => page.locator("aside") const sidebar = (page: Page) => page.locator("aside")
// A space path redirects to the space's entry room as the page mounts, and modal.ts closes every // A space path redirects as the page mounts, and modal.ts closes every open modal on navigation.
// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it.
const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/.")) const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/."))
// Search is a dialog the nav opens rather than a page of its own. // Search is a dialog the nav opens rather than a page of its own.
@ -71,66 +61,24 @@ const profileMenu = (page: Page) => page.locator("button.button-circle.button-gh
const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first() const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first()
test("US-074 find a person", async ({seed, as}) => { test("US-074 find a person", async ({seed, as}) => {
// Sixty of them, so that the ten the dialog lists are visibly the best matches rather than
// everyone who matched.
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")
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
// 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.",
})
space.relayList(user.bob) space.relayList(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, spacePath(scenario.space("space").url), {context: DESKTOP}) const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
const term = searchTerm(page) const bobCard = searchResults(page).filter({hasText: "Bob Barnacle"})
const cards = searchResults(page)
await enteredSpace(page) await enteredSpace(page)
await openSearch(page) await openSearch(page)
await term.fill("Searcher") await searchTerm(page).fill("Barnacle")
// 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()).toBe(10)
// 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(bobCard).toHaveCount(1)
await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0)
await viewProfile(bobCard).click() await viewProfile(bobCard).click()
@ -139,9 +87,6 @@ test("US-074 find a person", async ({seed, as}) => {
}) })
test("US-075 view someone's profile", async ({seed, as}) => { 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, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
const other = relay("other") const other = relay("other")
@ -150,8 +95,7 @@ test("US-075 view someone's profile", async ({seed, as}) => {
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// A second space alice does not belong to, so "Member" is a claim about the overlap rather // A second space alice does not belong to, so "Member" is a claim about the overlap.
// than about every space bob is in.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.bob, "lounge") other.join(user.bob, "lounge")
@ -161,9 +105,6 @@ test("US-075 view someone's profile", async ({seed, as}) => {
.writer() .writer()
.setName("Bob Barnacle") .setName("Bob Barnacle")
.setAbout("Deckhand, dockside cook, and keeper of the ship's cat.") .setAbout("Deckhand, dockside cook, and keeper of the ship's cat.")
.setPicture(avatar)
.setBanner(banner)
.setWebsite("bobbarnacle.example")
.renderTemplate(), .renderTemplate(),
) )
@ -180,11 +121,9 @@ test("US-075 view someone's profile", async ({seed, as}) => {
space.relayList(user.bob) space.relayList(user.bob)
// Carol belongs to no space at all, which is what the panel's empty state is about.
space.member(user.carol) space.member(user.carol)
space.profile(user.carol, {name: "Carol Cutter"}) space.profile(user.carol, {name: "Carol Cutter"})
// An expired status is one its author asked relays to stop serving, so it is not what she is // An expired status is one its author asked relays to stop serving.
// up to any more.
space.event( space.event(
user.carol, user.carol,
makeEvent(STATUS, { makeEvent(STATUS, {
@ -200,50 +139,27 @@ test("US-075 view someone's profile", async ({seed, as}) => {
const space = scenario.space("space") const space = scenario.space("space")
const other = scenario.space("other") const other = scenario.space("other")
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: CLIPBOARD}) const page = await as(users.alice, profilePath(users.bob.pubkey), {context: DESKTOP})
const region = pageContent(page) const region = pageContent(page)
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible() 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() await expect(region.getByText("Deckhand, dockside cook")).toBeVisible()
await expect(region.getByRole("link", {name: "Scrubbing the decks"})).toHaveAttribute( await expect(region.getByRole("link", {name: "Scrubbing the decks"})).toHaveAttribute(
"href", "href",
"https://bobbarnacle.example/decks", "https://bobbarnacle.example/decks",
) )
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 spaces = sidebar(page).locator(".card.card-sm").filter({hasText: "Spaces"})
const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`) const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`)
const otherLink = spaces.locator(`a[href="${spacePath(other.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(spaceLink).toContainText("Member")
await expect(otherLink).toBeVisible() await expect(otherLink).toBeVisible()
await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0) await expect(otherLink).not.toContainText("Member")
await spaceLink.click()
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
await page.goto(profilePath(users.carol.pubkey)) await page.goto(profilePath(users.carol.pubkey))
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible() await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
await expect(region.getByText("Ashore until spring")).toHaveCount(0) await expect(region.getByText("Ashore until spring")).toHaveCount(0)
}) })
@ -267,20 +183,14 @@ test("US-076 follow and unfollow", async ({seed, as}) => {
await follow.click() await follow.click()
// The same page, still on the same url: the label flips where it stands.
await expect(unfollow).toBeVisible() await expect(unfollow).toBeVisible()
await expect(follow).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
await unfollow.click() await unfollow.click()
await expect(follow).toBeVisible() await expect(follow).toBeVisible()
await expect(unfollow).toHaveCount(0)
}) })
test("US-077 see web-of-trust standing build up", async ({seed, as}) => { test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
const carolAvatar = "https://images.test/carol-avatar.png"
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
@ -289,20 +199,18 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
space.join(user.bob, "general") space.join(user.bob, "general")
space.join(user.carol, "general") space.join(user.carol, "general")
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."}) space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker.", picture: carolAvatar}) space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker."})
space.relayList(user.alice) space.relayList(user.alice)
space.relayList(user.bob) space.relayList(user.bob)
space.relayList(user.carol) space.relayList(user.carol)
// Carol already follows bob, so alice following carol is the one thing that has to happen // Carol already follows bob, so alice following carol is the one thing that goes through the ui.
// through the ui for his standing to move.
space.event(user.carol, () => space.event(user.carol, () =>
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), 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 // Every hop here is a link click rather than a navigation, so the follow stays in one client.
// in the client that published it.
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP}) const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
const term = searchTerm(page) const term = searchTerm(page)
const cards = searchResults(page) const cards = searchResults(page)
@ -310,26 +218,17 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
const carolCard = cards.filter({hasText: "Carol Cutter"}) const carolCard = cards.filter({hasText: "Carol Cutter"})
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"}) 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 enteredSpace(page) await enteredSpace(page)
await openSearch(page) await openSearch(page)
await term.fill("Barnacle") await term.fill("Barnacle")
await expect(bobCard).toHaveCount(1) await expect(bobCard).toHaveCount(1)
await expect(bobCard.locator("circle.wot-highlight")).toBeAttached()
const emptyRing = await ring()
await viewProfile(bobCard).click() await viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are // Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100".
// exactly the readings this is supposed to rule out.
await expect(reputation()).toContainText(/\b0 \/ 100\b/) await expect(reputation()).toContainText(/\b0 \/ 100\b/)
await expect(reputation()).toContainText("This user is not well known in your network.")
await openSearch(page) await openSearch(page)
await term.fill("Cutter") await term.fill("Cutter")
@ -348,17 +247,13 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
await term.fill("Barnacle") await term.fill("Barnacle")
await expect(bobCard).toHaveCount(1) await expect(bobCard).toHaveCount(1)
await expect.poll(ring).toBeLessThan(emptyRing)
await viewProfile(bobCard).click() await viewProfile(bobCard).click()
await expect(reputation()).toContainText("Followed by 1+ people in your network.") await expect(reputation()).toContainText("Followed by 1+ people in your network.")
await expect(reputation()).toContainText("1 person you follow also follows Bob Barnacle.")
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
}) })
test("US-078 edit your own profile", async ({seed, as}) => { test("US-078 edit your own profile", async ({seed, as}) => {
const oldAvatar = "https://images.test/alice-avatar.png"
const newAvatar = uploadedUrl(WEBP, "webp") const newAvatar = uploadedUrl(WEBP, "webp")
const newBanner = uploadedUrl(GIF, "gif") const newBanner = uploadedUrl(GIF, "gif")
@ -373,8 +268,6 @@ test("US-078 edit your own profile", async ({seed, as}) => {
.writer() .writer()
.setName("Alice Anderson") .setName("Alice Anderson")
.setAbout("Ship's navigator.") .setAbout("Ship's navigator.")
.setPicture(oldAvatar)
.setNip05("alice@flotilla.test")
.renderTemplate(), .renderTemplate(),
) )
space.relayList(user.alice) space.relayList(user.alice)
@ -387,24 +280,14 @@ test("US-078 edit your own profile", async ({seed, as}) => {
const region = pageContent(page) const region = pageContent(page)
const edit = page.getByRole("button", {name: "Edit profile"}) 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() await edit.click()
// ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address.
const form = page.locator("form").first() const form = page.locator("form").first()
const nickname = form.locator('input[type="text"]').first() const nickname = form.getByRole("textbox", {name: "Nickname"})
const about = form.locator("textarea") 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."]') const picker = page.locator('label[aria-label="Drag and drop files here."]')
await expect(nickname).toHaveValue("Alice Anderson") 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 nickname.fill("Alice Anchor")
await about.fill("Ship's navigator and part-time cartographer.") await about.fill("Ship's navigator and part-time cartographer.")
@ -443,19 +326,17 @@ test("US-078 edit your own profile", async ({seed, as}) => {
}) })
test("US-079 read a person's notes", async ({seed, as}) => { test("US-079 read a person's notes", async ({seed, as}) => {
const avatar = "https://images.test/alice-avatar.png"
let carolNote!: Seeded let carolNote!: Seeded
let pinned!: Seeded let pinned!: Seeded
const scenario = await seed(({relay, user, at}) => { await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.join(user.carol, "general") space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anderson", picture: avatar}) space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.carol, {name: "Carol Cutter"}) space.profile(user.carol, {name: "Carol Cutter"})
space.relayList(user.alice) space.relayList(user.alice)
space.relayList(user.carol) space.relayList(user.carol)
@ -465,8 +346,7 @@ test("US-079 read a person's notes", async ({seed, as}) => {
makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}), makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}),
) )
// Older than the feed's first window, so the pin is fetched by id while the feed only // Older than the feed's first window, so the pin is fetched by id while the feed only pages to it.
// reaches it by paging — the order in which one note arrives down both routes.
pinned = space.event( pinned = space.event(
user.alice, user.alice,
makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(2, MONTH)}), makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(2, MONTH)}),
@ -506,74 +386,76 @@ test("US-079 read a person's notes", async ({seed, as}) => {
) )
}) })
const {url} = scenario.space("space")
const page = await as(users.bob, profilePath(users.alice.pubkey), {context: DESKTOP}) const page = await as(users.bob, profilePath(users.alice.pubkey), {context: DESKTOP})
const list = notes(page) const list = notes(page)
const newest = list.filter({hasText: "NEWEST"})
await expect(newest).toBeVisible() await expect(list.filter({hasText: "NEWEST"})).toBeVisible()
await expect(newest.getByText("Alice Anderson")).toBeVisible() await expect(list.filter({hasText: "MIDDLE"})).toBeVisible()
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
// The story asks for a relative timestamp; the app renders formatTimestamp — a short date plus a
// clock time — the same way every other content item does (thread items, chat items), so that
// shared convention is what a note's stamp reads as here.
await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/)
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0) // The pin outranks the newer notes.
// The pin outranks the newer notes; the rest are newest first.
await expect(list.first()).toContainText("PINNED") await expect(list.first()).toContainText("PINNED")
await expect(list.filter({hasText: "PINNED"})).toHaveCount(1) await expect(list.filter({hasText: "PINNED"})).toHaveCount(1)
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 // Nothing is scrolled here: the feed keeps widening its window until the page is full.
// what "loads older notes automatically" means.
await expect(list.filter({hasText: "OLDEST"})).toBeVisible() await expect(list.filter({hasText: "OLDEST"})).toBeVisible()
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
})
// A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so test("US-134 write a public post", async ({seed, as}) => {
// it goes out through the app's own primitives in her signed-in session, over her socket to the const post = "the wind has finally turned"
// space — which is the half of this the story is about.
const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
await alice.evaluate( const scenario = await seed(({relay, user}) => {
async ([relayUrl, content]) => { const space = relay("space")
const { const other = relay("other")
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({ space.room("general", {name: "General"})
event: await app.get().user.sign(make(1, {content})), space.join(user.alice, "general")
relays: [relayUrl], space.profile(user.alice, {name: "Alice Anderson"})
}) space.relayList(user.alice)
},
[url, "LIVE straight off the deck"] as const,
)
// The profile feed fetches on load rather than subscribing live, so bob sees it the next time he // Her second space is in no relay list of hers, so a post reaching it was routed there as a space.
// opens the page — where it lands above every unpinned note, below the pin. other.room("general", {name: "General"})
await page.reload() other.join(user.alice, "general")
})
await expect(list.filter({hasText: "LIVE"})).toBeVisible() const page = await as(users.alice, profilePath(users.alice.pubkey), {context: DESKTOP})
await expect(list.nth(1)).toContainText("LIVE")
// Carol's own note is on her profile; alice's reply to it is alice's, so it isn't here. await page.getByRole("button", {name: "Create post"}).click()
await page.goto(profilePath(users.carol.pubkey))
await expect(notes(page).filter({hasText: "Anyone seen the tide charts?"})).toBeVisible() const composer = topDialog(page)
await expect(notes(page).filter({hasText: "REPLY"})).toHaveCount(0)
await expect(composer.getByText("Create a Public Post")).toBeVisible()
await noteEditor(composer).click()
await noteEditor(composer).pressSequentially(post)
await composer.getByRole("button", {name: "Post"}).click()
await expect(notes(page).filter({hasText: post})).toBeVisible()
await expect
.poll(() =>
uniq(
getPublished(page.context())
.filter(({event}) => event.content === post)
.map(({url}) => url),
).sort(),
)
.toEqual([scenario.space("other").url, scenario.space("space").url].sort())
// The same composer is how a post starts from home, out of the feed it posts into.
await page.goto("/home")
const network = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})})
await network.getByRole("button", {name: "Create post"}).click()
await expect(topDialog(page).getByText("Create a Public Post")).toBeVisible()
}) })
test("US-080 preview a profile from anywhere", async ({seed, as}) => { 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}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -582,22 +464,13 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
space.profile(user.bob, { space.profile(user.bob, {
name: "Bob Barnacle", name: "Bob Barnacle",
about: "Deckhand, dockside cook, and keeper of the ship's cat.", 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))
space.event(
user.bob,
makeEvent(STATUS, {content: "Scrubbing the decks", tags: [["d", "general"]]}),
)
space.relayList(user.bob) space.relayList(user.bob)
}) })
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP}) 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 // The card's aria-label keeps the npub it fell back to at first render, so it is found by the name.
// `$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 const preview = page
.locator(".card.card-interactive") .locator(".card.card-interactive")
.filter({hasText: "Bob Barnacle"}) .filter({hasText: "Bob Barnacle"})
@ -611,31 +484,13 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
const profile = page.getByRole("dialog", {name: "Profile details"}) const profile = page.getByRole("dialog", {name: "Profile details"})
await expect(profile.getByText("Bob Barnacle")).toBeVisible() await expect(profile.getByText("Bob Barnacle")).toBeVisible()
await expect(profile.locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(profile.getByText("Deckhand, dockside cook")).toBeVisible() await expect(profile.getByText("Deckhand, dockside cook")).toBeVisible()
await expect(profile.getByText("Scrubbing the decks")).toBeVisible()
await expect(profile.getByText(/Last active/)).toBeVisible()
await expect(profile.getByRole("button", {name: "Close dialog"})).toBeVisible()
for (const key of ["Tab", "Shift+Tab"]) {
await page.keyboard.press(key)
expect(await profile.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true)
}
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(page.locator(".dialog")).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
await expect(preview).toBeFocused()
await preview.click()
await topDialog(page).getByRole("button", {name: "Go back"}).click()
// Closed, and alice is exactly where she opened it from. // Closed, and alice is exactly where she opened it from.
await expect(page.locator(".dialog")).toHaveCount(0) await expect(page.locator(".dialog")).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`)) await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
await expect(preview).toBeVisible()
await preview.click() await preview.click()
await page.getByRole("button", {name: "View Full Profile"}).click() await page.getByRole("button", {name: "View Full Profile"}).click()
@ -663,63 +518,25 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible() await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
const info = topDialog(page) const info = topDialog(page)
// Each of these is an input and its copy button inside one bordered label, in dialog order. // The pubkey and its copy button share the dialog's second bordered label.
const linkField = info.locator("label.input").first()
const pubkeyField = info.locator("label.input").nth(1) const pubkeyField = info.locator("label.input").nth(1)
const link = linkField.locator('input[type="text"]')
const pubkey = pubkeyField.locator('input[type="text"]')
// The profile was signed during this test, so the creation date is today's. A FieldInline puts await expect(pubkeyField.locator('input[type="text"]')).toHaveValue(npubEncode(users.bob.pubkey))
// 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 linkField.getByRole("button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
await pubkeyField.getByRole("button").click() await pubkeyField.getByRole("button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey)) 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 page.getByRole("button", {name: "Got it"}).click()
await profileMenu(page).click() await profileMenu(page).click()
await page.getByRole("button", {name: "Share"}).click() await page.getByRole("button", {name: "Share"}).click()
// The menu that opened it is still mounted behind the dialog, and its own items run together as
// "Share Profile Info", so the dialog's heading is matched exactly.
await expect(page.getByText("Share Profile", {exact: true})).toBeVisible()
const share = topDialog(page) const share = topDialog(page)
const qr = share.locator("canvas")
const shareLink = share.locator("input")
await expect(qr).toBeVisible() await expect(share.locator("canvas")).toBeVisible()
expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0) await expect(share.locator("input")).toHaveValue(/nprofile1/)
// 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}) => { test("US-082 mute an account", async ({seed, as}) => {
@ -748,9 +565,7 @@ test("US-082 mute an account", async ({seed, as}) => {
const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"}) const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"})
const save = page.getByRole("button", {name: "Save Changes"}) const save = page.getByRole("button", {name: "Save Changes"})
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is // Typed slower than the search's own debounce, since the profile only arrives after a pause.
// 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 await page
.getByPlaceholder("Search for profiles...") .getByPlaceholder("Search for profiles...")
.pressSequentially("Bob Barnacle", {delay: 700}) .pressSequentially("Bob Barnacle", {delay: 700})
@ -767,8 +582,7 @@ test("US-082 mute an account", async ({seed, as}) => {
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") 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 // Through the nav rather than a fresh navigation, so this is what the publishing client believes.
// just published the mute believes.
const openBobsProfile = async () => { const openBobsProfile = async () => {
await openSearch(page) await openSearch(page)
await searchTerm(page).fill("Barnacle") await searchTerm(page).fill("Barnacle")
@ -785,21 +599,13 @@ test("US-082 mute an account", async ({seed, as}) => {
const list = notes(page) const list = notes(page)
await expect(list).toHaveCount(2) 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 expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0)
await list.first().getByRole("button", {name: "Show anyway"}).click() await list.first().getByRole("button", {name: "Show anyway"}).click()
await expect(list.first()).toContainText("The gulls have taken the mainsail") 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.goto("/settings/content")
await page.getByRole("link", {name: "Content"}).click()
await expect(badge).toBeVisible()
await page.reload()
await expect(badge).toBeVisible() await expect(badge).toBeVisible()
@ -816,5 +622,144 @@ test("US-082 mute an account", async ({seed, as}) => {
await expect(list).toHaveCount(2) await expect(list).toHaveCount(2)
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible() 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("The gulls have taken the mainsail")).toBeVisible()
await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0) })
test("US-131 say where you are and find members on a map", 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 Anders"})
// Bob shares a town already, so the directory has a pin before alice adds hers.
space.event(user.bob, () =>
space
.kind(Profile)
.writer()
.update({name: "Bob Barnacle", location: "Wellington, New Zealand"})
.addTags(["g", "rbsm1"])
.renderTemplate(),
)
space.relayList(user.alice)
space.relayList(user.bob)
})
const {url} = scenario.space("space")
const page = await as(users.alice, profilePath(users.alice.pubkey))
await mockGeocoding(page.context(), [
{
type: "city",
name: "Portland",
state: "Oregon",
country: "United States",
latitude: 45.52345,
longitude: -122.67621,
},
{
type: "state",
name: "Oregon",
country: "United States",
latitude: 43.93179,
longitude: -120.76562,
},
{
type: "street",
name: "Portland Avenue",
state: "Oregon",
country: "United States",
latitude: 45.51,
longitude: -122.65,
},
])
const openEditForm = async () => {
await page.getByRole("button", {name: "Edit profile"}).click()
return topDialog(page)
}
const form = await openEditForm()
await form.getByPlaceholder("Search for a city").fill("portl")
// A state is a place to be from; a street is not.
await expect(form.getByRole("button", {name: "Oregon, United States", exact: true})).toBeVisible()
await expect(
form.getByRole("button", {name: "Portland Avenue, Oregon, United States"}),
).toHaveCount(0)
await form.getByRole("button", {name: "Portland, Oregon, United States"}).click()
await form.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
await expect(pageContent(page).getByText("Portland, Oregon, United States")).toBeVisible()
const published = getPublishedEvents(page.context(), PROFILE)
expect(JSON.parse(published[0].content).location).toBe("Portland, Oregon, United States")
expect(published[0].tags).toContainEqual(["g", "c20fb"])
await page.goto(`${spacePath(url)}/directory`)
const showMap = page.getByRole("button", {name: "Map"})
await expect(showMap).toBeVisible()
await showMap.click()
const pins = page.locator(".leaflet-marker-icon")
await expect(pins).toHaveCount(2)
await pins.and(page.locator('[title="Bob Barnacle"]')).click()
const profile = page.getByRole("dialog", {name: "Profile details"})
await expect(profile.getByText("Bob Barnacle")).toBeVisible()
await expect(profile.getByText("Wellington, New Zealand")).toBeVisible()
await profile.getByRole("button", {name: "Close dialog"}).click()
// Searching narrows the map the same way it narrows the list.
await page.getByPlaceholder("Search people or roles...").fill("Anders")
await expect(pins).toHaveCount(1)
await showMap.click()
await expect(pins).toHaveCount(0)
// A state is not a pin, so choosing one takes alice off the map.
await page.goto(profilePath(users.alice.pubkey))
const secondForm = await openEditForm()
await expect(secondForm.getByText("Portland, Oregon, United States")).toBeVisible()
await secondForm.getByRole("button", {name: "Clear location"}).click()
await secondForm.getByPlaceholder("Search for a city").fill("oreg")
await secondForm.getByRole("button", {name: "Oregon, United States", exact: true}).click()
await secondForm.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
const republished = getPublishedEvents(page.context(), PROFILE)
expect(JSON.parse(republished[1].content).location).toBe("Oregon, United States")
expect(republished[1].tags.map(tag => tag[0])).not.toContain("g")
await page.goto(`${spacePath(url)}/directory`)
await page.getByPlaceholder("Search people or roles...").fill("Anders")
// A state is the member card's to show, since the map cannot.
await expect(pageContent(page).getByText("Oregon, United States")).toBeVisible()
// The map is offered whether or not anyone is on it, so an empty one says so.
await page.getByRole("button", {name: "Map"}).click()
await expect(pageContent(page).getByText("No locations found.")).toBeVisible()
}) })

View file

@ -1,7 +1,7 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19" import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {DAY, HOUR, MINUTE, WEEK, bech32ToHex, now} from "@welshman/lib" import {DAY, HOUR, MINUTE, WEEK, bech32ToHex, int, now} from "@welshman/lib"
import {getLnUrl} from "@welshman/util" import {getLnUrl} from "@welshman/util"
import {Profile, displayPubkey} from "@welshman/domain" import {Profile} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import { import {
composer, composer,
@ -30,9 +30,7 @@ import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// The room's way back to the live end, up only while the bottom of the container isn't it. // The room's way back to the live end, up only while the bottom of the container isn't it.
const jumpToNewest = (page: Page) => page.getByRole("button", {name: "Jump to newest"}) const jumpToNewest = (page: Page) => page.getByRole("button", {name: "Jump to newest"})
// Each of these menus hides itself once the pointer leaves it, which is how one is dismissed // Each of these menus hides itself once the pointer leaves it, and the top left corner is outside.
// 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) const dismissMenu = (page: Page) => page.mouse.move(0, 0)
const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"}) const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"})
@ -40,21 +38,6 @@ const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details
const openRoomDetailMenu = (page: Page) => const openRoomDetailMenu = (page: Page) =>
roomDetail(page).getByRole("button", {name: "Room options"}).click() roomDetail(page).getByRole("button", {name: "Room options"}).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)
// A FieldInline puts its control in the div immediately after its label. // A FieldInline puts its control in the div immediately after its label.
const field = (form: Locator, label: string) => const field = (form: Locator, label: string) =>
form form
@ -63,8 +46,7 @@ const field = (form: Locator, label: string) =>
.locator("xpath=following-sibling::div") .locator("xpath=following-sibling::div")
.locator("input[type=text]") .locator("input[type=text]")
// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled // RoomForm's permission toggles are a bare checkbox beside their own text.
// control.
const permission = (form: Locator, label: string) => const permission = (form: Locator, label: string) =>
form.getByText(label).locator("xpath=preceding-sibling::input") form.getByText(label).locator("xpath=preceding-sibling::input")
@ -73,8 +55,7 @@ const reactionPill = (page: Page, text: string) =>
const react = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper") const react = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
// Outbox routing resolves everything about a person through their relay list, and a gift wrap only // Outbox routing resolves a person through their relay list, and a gift wrap needs one too.
// reaches somebody who has said where their messages go.
const seedChatter = (space: SeededSpace, user: TestUser) => { const seedChatter = (space: SeededSpace, user: TestUser) => {
space.relayList(user) space.relayList(user)
space.messagingRelayList(user) space.messagingRelayList(user)
@ -87,15 +68,14 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) =
space.room("general", {name: "General"}) space.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anchor", picture: "https://images.test/alice.png"}) space.profile(user.alice, {name: "Alice Anchor"})
space.message(user.bob, "general", "morning all", at(2, HOUR)) space.message(user.bob, "general", "morning all", at(2, HOUR))
}) })
const {url} = scenario.space("space") const {url} = scenario.space("space")
const path = roomPath(url, "general") const path = roomPath(url, "general")
// Two browser contexts, two identities, one relay: bob's page is already listening when alice // Two browser contexts, two identities, one relay: bob's page is already listening when alice sends.
// sends, so the message reaches him over the wire.
const alice = await as(users.alice, path) const alice = await as(users.alice, path)
const bob = await as(users.bob, path) const bob = await as(users.bob, path)
@ -109,8 +89,6 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) =
await expect(sent).toContainText("anyone there?") await expect(sent).toContainText("anyone there?")
await expect(sent).toContainText("Alice Anchor") 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() await expect(message(bob, "anyone there?")).toBeVisible()
@ -119,9 +97,6 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) =
await composer(alice).press("Shift+Enter") await composer(alice).press("Shift+Enter")
await composer(alice).pressSequentially("second line") 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 // ...and cmd/ctrl+enter sends from the middle of it
await composer(alice).press("ArrowUp") await composer(alice).press("ArrowUp")
await composer(alice).press("ControlOrMeta+Enter") await composer(alice).press("ControlOrMeta+Enter")
@ -131,9 +106,7 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) =
await expect(message(bob, "second line")).toContainText("first line") await expect(message(bob, "second line")).toContainText("first line")
}) })
// Two people typing in the same second used to leave every client with a different transcript, // Two messages sharing a second are ordered by event id, which is the same on every client.
// since the timestamp alone gave the merge nothing to break the tie on and each client saw its own
// message arrive first. Ordering falls back to the event id, which is the same everywhere.
test("US-118 messages sent in the same second are in one order for everyone", async ({ test("US-118 messages sent in the same second are in one order for everyone", async ({
seed, seed,
as, as,
@ -167,8 +140,7 @@ test("US-118 messages sent in the same second are in one order for everyone", as
items.map(item => item.getAttribute("data-event")), items.map(item => item.getAttribute("data-event")),
) )
// The room reads newest first in the dom, so the ids run the other way from the order the feed // The room reads newest first in the dom, so the ids run the other way from the feed.
// holds them in
expect(rendered.reverse()).toEqual(tied.map(({id}) => id).sort()) expect(rendered.reverse()).toEqual(tied.map(({id}) => id).sort())
}) })
@ -191,62 +163,26 @@ test("US-019 join and leave a room", async ({seed, as}) => {
const alice = await as(users.alice, path) const alice = await as(users.alice, path)
await expect(message(bob, "morning all")).toBeVisible() await expect(message(bob, "morning all")).toBeVisible()
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
await openRoomDetail(bob) await openRoomDetail(bob)
await openRoomDetailMenu(bob) await openRoomDetailMenu(bob)
await bob.getByRole("button", {name: "Join member list"}).click() await bob.getByRole("button", {name: "Join member list"}).click()
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms") await expect(bob.getByText("You have joined the room.")).toBeVisible()
// Everyone else in the room finds him there. The membership events a client listens for live are // The membership events a client listens for live are the ones naming itself.
// the ones naming itself, so somebody else's arrival is read with the rest of the room.
await alice.reload() await alice.reload()
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"}) await expect(alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})).toBeVisible()
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 openRoomDetailMenu(bob)
await bob.getByRole("button", {name: "Leave member list"}).click() await bob.getByRole("button", {name: "Leave member list"}).click()
await expect(bob.getByRole("alert")).toHaveClass(/text-content/) await expect(bob.getByText("You have left the room.")).toBeVisible()
await openRoomDetailMenu(bob) await openRoomDetailMenu(bob)
await expect(bob.getByRole("button", {name: "Join member list"})).toBeVisible() await expect(bob.getByRole("button", {name: "Join member list"})).toBeVisible()
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
})
test("US-122 long room names keep header and dialog actions available", async ({seed, as}) => {
const name = "A very long room name for coordinating every ship arriving in the harbor today"
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("harbor", {name})
space.join(user.alice, "harbor")
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "harbor"), {
context: {viewport: {width: 800, height: 600}},
})
const header = pageBar(page)
await expect(header.getByRole("button", {name: "Search"})).toBeVisible()
await expect(header.getByRole("button", {name: "Room details"})).toBeVisible()
expect(await header.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await openRoomDetail(page)
const detail = roomDetail(page)
await expect(detail.getByRole("button", {name: "Room options"})).toBeVisible()
expect(await detail.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
}) })
test("US-020 create, edit, and delete a room", async ({seed, as}) => { test("US-020 create, edit, and delete a room", async ({seed, as}) => {
@ -270,7 +206,6 @@ test("US-020 create, edit, and delete a room", async ({seed, as}) => {
await createForm.getByRole("button", {name: "Create Room"}).click() await createForm.getByRole("button", {name: "Create Room"}).click()
await expect(pageBar(admin)).toContainText("Ship Log") 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 // 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()! const h = new URL(admin.url()).pathname.split("/").pop()!
@ -292,20 +227,8 @@ test("US-020 create, edit, and delete a room", async ({seed, as}) => {
await expect(roomDetail(admin).getByRole("button", {name: "Private"})).toBeVisible() 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 openRoomDetailMenu(admin)
await admin.getByRole("button", {name: "Delete Room"}).click() 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 admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin).not.toHaveURL(pathPattern(h)) await expect(admin).not.toHaveURL(pathPattern(h))
@ -342,8 +265,7 @@ test("US-121 land somewhere after deleting the room you are in", async ({seed, a
await admin.getByRole("button", {name: "Delete Room"}).click() await admin.getByRole("button", {name: "Delete Room"}).click()
await admin.getByRole("button", {name: "Confirm"}).click() await admin.getByRole("button", {name: "Confirm"}).click()
// The space root renders nothing on a wide screen; it hands off to whichever page of the space // The space root renders nothing on a wide screen, handing off to the page of the space open last.
// was open last, and the room that was just deleted is the page it remembers.
await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/")) await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/"))
await expect(roomLink(admin, "General")).toBeVisible() await expect(roomLink(admin, "General")).toBeVisible()
@ -359,8 +281,7 @@ test("US-021 request access to a private room and get approved", async ({seed, a
const space = relay("space") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
// `private` is what hides the room's history from non-members; `closed` is what keeps a join // `private` hides the room's history from non-members; `closed` makes a join a request.
// 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.room("wardroom", {name: "Wardroom", private: true, closed: true})
space.join(user.admin, "general", "wardroom") space.join(user.admin, "general", "wardroom")
space.join(user.alice, "general", "wardroom") space.join(user.alice, "general", "wardroom")
@ -381,8 +302,7 @@ test("US-021 request access to a private room and get approved", async ({seed, a
await expect(carol.getByText("You aren't currently a member of this room.")).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 expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
// A private room offers to join rather than to ask, and a closed one turns that into a request // A private room offers to join rather than to ask, and a closed one turns that into a request.
// an admin has to act on.
await carol.getByRole("button", {name: "Join Room"}).click() await carol.getByRole("button", {name: "Join Room"}).click()
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible() await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
@ -399,8 +319,7 @@ test("US-021 request access to a private room and get approved", async ({seed, a
await expect(admin.getByText("Member has been added to the room!")).toBeVisible() await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
// The history the relay refused her is fetched when the room is next read, so this is what she // The history the relay refused her is fetched when the room is next read.
// finds on her way back in rather than something that fills in behind her.
await carol.reload() await carol.reload()
await expect(carol.getByText("the charts are in the locker")).toBeVisible() await expect(carol.getByText("the charts are in the locker")).toBeVisible()
@ -413,7 +332,6 @@ test("US-021 request access to a private room and get approved", async ({seed, a
test("US-022 bring people into a room", async ({seed, as}) => { test("US-022 bring people into a room", async ({seed, as}) => {
const dora = makeTestUser("dora") const dora = makeTestUser("dora")
const erik = makeTestUser("erik")
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -422,8 +340,7 @@ test("US-022 bring people into a room", async ({seed, as}) => {
space.join(user.admin, "general") space.join(user.admin, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"}) 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 // A space member who isn't in the room yet, whose message loads her profile into the client.
// client that goes looking for her.
space.join(dora) space.join(dora)
space.profile(dora, {name: "Dora Deckhand"}) space.profile(dora, {name: "Dora Deckhand"})
space.message(dora, "general", "passing through", at(3, HOUR)) space.message(dora, "general", "passing through", at(3, HOUR))
@ -437,20 +354,11 @@ test("US-022 bring people into a room", async ({seed, as}) => {
await openRoomDetail(admin) await openRoomDetail(admin)
await admin.getByRole("button", {name: "Create invite"}).click() await admin.getByRole("button", {name: "Create invite"}).click()
const inviteModal = dialog(admin, "Create a Room Invite") const invite = dialog(admin, "Create a Room Invite").locator("input[readonly]")
// The link and its copy button share one bordered label, the only readonly field in the dialog.
const inviteField = inviteModal.locator("label:has(input[readonly])")
const invite = inviteField.locator("input[readonly]")
await expect(inviteModal.locator("canvas")).toBeVisible()
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/) await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
await inviteField.getByRole("button").click() // A path rather than an absolute url, which would leave the test's own dev server for the platform's.
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 link = new URL(await invite.inputValue())
const carol = await as(users.carol, `/join${link.search}`) const carol = await as(users.carol, `/join${link.search}`)
@ -478,19 +386,6 @@ test("US-022 bring people into a room", async ({seed, as}) => {
await expect(admin.getByText("Members have successfully been added!")).toBeVisible() await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).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"}) const doraCard = members.locator(".card").filter({hasText: "Dora Deckhand"})
await doraCard.getByRole("button").last().click() await doraCard.getByRole("button").last().click()
@ -499,14 +394,6 @@ test("US-022 bring people into a room", async ({seed, as}) => {
await expect(admin.getByText("Member has successfully been removed!")).toBeVisible() await expect(admin.getByText("Member has successfully been removed!")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).toHaveCount(0) 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}) => { test("US-023 reply to a message", async ({seed, as}) => {
@ -530,24 +417,8 @@ test("US-023 reply to a message", async ({seed, as}) => {
await messageActions(bob, "the tide turns at six").nth(2).click() await messageActions(bob, "the tide turns at six").nth(2).click()
const banner = bob.getByText("Replying to @Alice Anchor") await expect(bob.getByText("Replying to @Alice Anchor")).toBeVisible()
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 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(bob, "aye, I'll be there")).toContainText("the tide turns at six")
@ -576,9 +447,7 @@ test("US-024 edit or delete a message you sent", async ({seed, as}) => {
await expect(message(bob, "we sail at dwan")).toBeVisible() await expect(message(bob, "we sail at dwan")).toBeVisible()
// Her edit republishes with her original timestamp, and two messages sharing a second are // Her edit republishes with her original timestamp, and it changes the event id US-118 ties on.
// ordered by event id (US-118) -- which the edit changes. His reply waits her second out, so
// the order asserted below is about the timestamp rather than a coin flip on the new id.
const sent = now() const sent = now()
while (now() === sent) { while (now() === sent) {
@ -589,30 +458,7 @@ test("US-024 edit or delete a message you sent", async ({seed, as}) => {
await expect(message(alice, "spelling?")).toBeVisible() await expect(message(alice, "spelling?")).toBeVisible()
// Her own message offers zap, emoji, reply, edit and a menu; his offers no edit... // His message offers her no delete
await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5)
await expect(messageActions(alice, "spelling?")).toHaveCount(4)
await expect(message(alice, "we sail at dwan").locator("button button")).toHaveCount(0)
const moreOptions = message(alice, "we sail at dwan").getByRole("button", {
name: "More options",
})
await moreOptions.focus()
await expect(message(alice, "we sail at dwan").locator(".room__item-actions")).toHaveCSS(
"opacity",
"1",
)
await moreOptions.press("Enter")
const messageDetails = alice.getByRole("button", {name: "Message Details"})
await messageDetails.focus()
await expect(messageDetails).toBeFocused()
await alice.keyboard.press("Escape")
await expect(moreOptions).toBeFocused()
// ...and no delete either
await openMessageMenu(alice, "spelling?") await openMessageMenu(alice, "spelling?")
await expect(alice.getByRole("button", {name: "Report Content"})).toBeVisible() await expect(alice.getByRole("button", {name: "Report Content"})).toBeVisible()
@ -677,22 +523,7 @@ test("US-025 react to a message", async ({seed, as}) => {
await expect(message(carol, "we made port")).toBeVisible() await expect(message(carol, "we made port")).toBeVisible()
await expect(message(bob, "we made port")).toBeVisible() await expect(message(bob, "we made port")).toBeVisible()
const reaction = message(carol, "we made port").getByRole("button", {name: "Add a reaction"}) await react(carol, message(carol, "we made port").getByRole("button", {name: "Add a reaction"}))
await reaction.focus()
await reaction.press("Enter")
const picker = carol.locator("emoji-picker").filter({visible: true})
const search = picker.locator("input.search")
await expect(picker).toBeVisible()
await search.focus()
await expect(search).toBeFocused()
await carol.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
await expect(reaction).toBeFocused()
await react(carol, reaction)
await expect(reactionPill(carol, "we made port")).toBeVisible() await expect(reactionPill(carol, "we made port")).toBeVisible()
await expect(reactionPill(bob, "we made port")).toBeVisible() await expect(reactionPill(bob, "we made port")).toBeVisible()
@ -705,7 +536,6 @@ test("US-025 react to a message", async ({seed, as}) => {
await reactionPill(carol, "we made port").click() 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.toContainText("2")
await expect(reactionPill(carol, "we made port")).not.toHaveClass(/button-primary/)
await expect(reactionPill(bob, "we made port")).not.toContainText("2") 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 // On a phone, a pill she hasn't joined shows who reacted rather than adding her own
@ -715,19 +545,11 @@ test("US-025 react to a message", async ({seed, as}) => {
// The phone layout is what puts the reactor list behind a pill, so wait for it to be in effect. // The phone layout is what puts the reactor list behind a pill, so wait for it to be in effect.
await expect(phone.getByRole("button", {name: "Open space menu"})).toBeVisible() await expect(phone.getByRole("button", {name: "Open space menu"})).toBeVisible()
await expect(reactionPill(phone, "we made port")).toBeVisible()
// Her retraction has to have reached this page first: a pill she is part of toggles her reaction
// off instead of opening the list.
await expect(reactionPill(phone, "we made port")).not.toHaveClass(/button-primary/)
await reactionPill(phone, "we made port").click() await reactionPill(phone, "we made port").click()
await expect(phone.getByText("Reacted to this message")).toBeVisible() await expect(phone.getByText("Reacted to this message")).toBeVisible()
// The list resolves each reactor's profile as it renders; the dialog's own title is whatever name // Exact, because the room behind the dialog names him too, as "@Bob Barnacle".
// was known when the pill was clicked, which on a page this fresh is often still an npub. Exact,
// because the room behind the dialog names him too, as "@Bob Barnacle".
await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible() await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible()
}) })
@ -752,8 +574,6 @@ test("US-026 pin a message and browse pins", async ({seed, as}) => {
await openMessageMenu(admin, "muster at eight bells") await openMessageMenu(admin, "muster at eight bells")
await admin.getByRole("button", {name: "Pin Message"}).click() await admin.getByRole("button", {name: "Pin Message"}).click()
await expect(admin.getByText("Message pinned")).toBeVisible()
const banner = admin.locator(".room-pins") const banner = admin.locator(".room-pins")
await expect(banner).toContainText("muster at eight bells") await expect(banner).toContainText("muster at eight bells")
@ -772,8 +592,6 @@ test("US-026 pin a message and browse pins", async ({seed, as}) => {
await banner.getByRole("button", {name: "All pinned messages"}).click() await banner.getByRole("button", {name: "All pinned messages"}).click()
await pins.getByRole("button", {name: "Unpin"}).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) await expect(banner).toHaveCount(0)
}) })
@ -804,34 +622,17 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
const term = page.getByPlaceholder("Search this room...") const term = page.getByPlaceholder("Search this room...")
const search = dialog(page, "Search") const search = dialog(page, "Search")
await expect(term).toBeFocused()
for (const key of ["Tab", "Tab", "Shift+Tab"]) {
await page.keyboard.press(key)
expect(await search.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true)
}
await term.focus()
await term.fill("harbor") 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 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 term.fill("harbor pilot")
await search.getByText("harbor pilot is booked").click() await search.getByText("harbor pilot is booked").click()
await expect(search).toHaveCount(0) await expect(search).toHaveCount(0)
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
// A permalink to one message lands on it. This room is three messages long, so the window that // This room is three messages long, so the window runs to the present and the newest loads too.
// opens with it runs all the way to the present and the newest message is loaded alongside —
// US-027a is where the button that stands in for that gap is exercised.
await page.goto(`${path}?at=${older.event.created_at}`) await page.goto(`${path}?at=${older.event.created_at}`)
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
@ -839,11 +640,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
await expect(jumpToNewest(page)).toHaveCount(0) await expect(jumpToNewest(page)).toHaveCount(0)
}) })
// A push notification links to the message it announced, which is near the newest end of the room, // A push notification links near the newest end, so the jump lands at the bottom with nothing below.
// so the jump lands at the bottom with nowhere left to scroll down to. Anything published since —
// another message, a membership event — is newer than the one linked to, so "is this the last
// event in the room" is the wrong question to hang the button on; whether the loaded window has
// caught up to the present is the right one.
test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => { test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => {
let recent!: SeededEvent let recent!: SeededEvent
@ -874,6 +671,46 @@ test("US-027a a permalink near the newest end lands at the bottom", async ({seed
await expect(jumpToNewest(page)).toHaveCount(0) await expect(jumpToNewest(page)).toHaveCount(0)
}) })
// A permalink pins the message it lands on, and the reader asking for the unread divider takes over.
test("US-027b jump to the unread divider from a permalink", async ({seed, as}) => {
let newest!: SeededEvent
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")
// Deep enough that the jump centres what it lands on rather than clamping at the bottom.
for (let i = 0; i < 120; i++) {
space.message(user.bob, "general", `harbor watch note ${i}`, at(400 - i * 3, MINUTE))
}
newest = space.message(user.bob, "general", "and the tide is with us", at(2, MINUTE))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
// Alice has read none of this, so the divider is screens above the message she lands on.
const page = await as(users.alice, `${path}?at=${newest.event.created_at}`, {
checked: {[path]: now() - int(12, HOUR)},
})
await expect(page.locator(`[data-event="${newest.id}"]`)).toBeInViewport()
const divider = page.locator("#new-messages")
await page.getByRole("button", {name: "New Messages"}).click()
await expect(divider).toBeInViewport()
// The pin is what would pull her back down, a frame or two after the scroll starts.
await page.waitForTimeout(1000)
await expect(page.locator(`[data-event="${newest.id}"]`)).not.toBeInViewport()
})
test("US-028 share a message somewhere else", async ({seed, as}) => { test("US-028 share a message somewhere else", async ({seed, as}) => {
let shared!: SeededEvent let shared!: SeededEvent
@ -920,7 +757,6 @@ test("US-028 share a message somewhere else", async ({seed, as}) => {
expect(permalink.search).toBe( expect(permalink.search).toBe(
`?at=${shared.event.created_at}&event=${neventEncode({id: shared.id, relays: [url]})}`, `?at=${shared.event.created_at}&event=${neventEncode({id: shared.id, relays: [url]})}`,
) )
expect(permalink.hash).toBe("")
await picker.getByRole("button", {name: "Random"}).click() await picker.getByRole("button", {name: "Random"}).click()
await picker.getByRole("button", {name: /^Share/}).click() await picker.getByRole("button", {name: /^Share/}).click()
@ -958,7 +794,7 @@ test("US-119 have a message read out loud", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const alice = await as(users.alice, roomPath(url, "general")) const alice = await as(users.alice, roomPath(url, "general"))
// Long enough that playback is still going when the controls below are exercised. // Long enough that playback is still going when it is stopped.
const spoken = await mockOpenRouterSpeech(alice.context(), 10) const spoken = await mockOpenRouterSpeech(alice.context(), 10)
// The mention has to have resolved on screen before it can be expected in what was spoken. // The mention has to have resolved on screen before it can be expected in what was spoken.
@ -980,44 +816,20 @@ test("US-119 have a message read out loud", async ({seed, as}) => {
await expect(alice.getByText("a message from Bob Barnacle")).toBeVisible() await expect(alice.getByText("a message from Bob Barnacle")).toBeVisible()
// The quote, the mention and the url are each named rather than spelled out, since none of them // The quote, the mention and the url are named rather than spelled out a character at a time.
// is intelligible read a character at a time.
expect(spoken).toEqual([ expect(spoken).toEqual([
"another message\n\nheads up Alice Anchor, the notice is at a link to harbor.example", "another message\n\nheads up Alice Anchor, the notice is at a link to harbor.example",
]) ])
// The app decodes what it is answered and rebuilds the container around the samples, so the
// duration is only right if that round trip kept every one of them.
await expect(alice.getByText("/ 0:10")).toBeVisible()
// The clip carries autoplay and the button follows the audio element's own play event, so it
// reads "Play message" until that fires. Waiting for it is what keeps the click below a pause:
// reading the label instead races the autoplay, and loses whenever playback starts in between.
const playPause = alice.getByRole("button", {name: /^(Play|Pause) message$/})
await expect(playPause).toHaveAttribute("aria-label", "Pause message")
await playPause.click()
await expect(playPause).toHaveAttribute("aria-label", "Play message")
const seek = alice.getByRole("slider", {name: "Seek within the message"})
await seek.fill("2")
await expect(alice.getByText("0:02 /")).toBeVisible()
await alice.getByRole("button", {name: "Stop reading"}).click() await alice.getByRole("button", {name: "Stop reading"}).click()
await expect(alice.getByText("a message from Bob Barnacle")).toHaveCount(0) await expect(alice.getByText("a message from Bob Barnacle")).toHaveCount(0)
}) })
test("US-115 connect a wallet without losing the zap you were composing", async ({seed, as}) => { test("US-115 connect a wallet without losing the zap you were composing", async ({seed, as}) => {
// The lightning address on bob's profile, and the lnurl endpoint it resolves to. Zapping only gets // Zapping only gets as far as a dialog once dufflepud answers with a zapper for that endpoint.
// as far as a dialog once dufflepud answers with a zapper for that endpoint.
const lud16 = "bob@zap.test" const lud16 = "bob@zap.test"
// A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of // A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of its own.
// its own even where, as here, nothing is ever paid.
const provider = makeTestUser("zapper") const provider = makeTestUser("zapper")
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
@ -1037,8 +849,7 @@ test("US-115 connect a wallet without losing the zap you were composing", async
const path = roomPath(url, "general") const path = roomPath(url, "general")
const page = await as(users.alice, path, {webln: {node: {alias: "Test Node"}}}) const page = await as(users.alice, path, {webln: {node: {alias: "Test Node"}}})
// Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` // Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs.
// installs, and before the navigation below, since a zapper is looked up once per page load.
await mockDufflepud(page.context(), { await mockDufflepud(page.context(), {
zappers: [ zappers: [
{ {
@ -1064,15 +875,30 @@ test("US-115 connect a wallet without losing the zap you were composing", async
const connect = dialog(page, "Connect a Wallet") const connect = dialog(page, "Connect a Wallet")
await expect(page.locator(".dialog-overlay[inert]")).toHaveCount(1)
await connect.getByRole("button", {name: "Connect with WebLN"}).click() await connect.getByRole("button", {name: "Connect with WebLN"}).click()
await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!") await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!")
await expect(connect).toHaveCount(0) await expect(connect).toHaveCount(0)
// The zap dialog was underneath rather than replaced, so it answers to the wallet she now has // The zap dialog was underneath rather than replaced, so the amount she had typed survived.
// instead of still asking for one, and the amount she had typed survived the detour.
await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0) await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0)
await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible() await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible()
await expect(amount).toHaveValue("210") await expect(amount).toHaveValue("210")
}) })
test("fills a room from a relay that never says it is done", 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", "the buoy is back on station", at(3, HOUR))
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"), {eoseless: [url]})
// Nothing will tell the feed its page is finished, so a room that waits for that stays empty.
await expect(message(page, "the buoy is back on station")).toBeVisible()
})

View file

@ -16,8 +16,7 @@ import {
users, users,
} from "../harness" } from "../harness"
// Every page in a space renders one PageContent, so the number added to the document is the number // Every page in a space renders one PageContent, so the count is how often the page was built.
// of times the page was built.
const watchPageBuilds = (page: Page) => const watchPageBuilds = (page: Page) =>
page.evaluate(() => { page.evaluate(() => {
const selector = "[data-component='PageContent']" const selector = "[data-component='PageContent']"
@ -70,10 +69,7 @@ test("keeps two spaces' contents on their own relays", async ({seed, as}) => {
await expect(page.getByText("only in space")).toBeVisible() await expect(page.getByText("only in space")).toBeVisible()
await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible() await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible()
// Alice belongs to both spaces, so the client is talking to the other relay at the same time — // toHaveCount(0) is equally satisfied by an element that has not loaded yet, so wait for both.
// its room and its messages just don't belong in this one. It syncs independently, so wait until
// both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that
// has not loaded yet, which would pass against a client that does conflate them.
await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true) await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true)
await expect await expect
.poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"])))) .poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"]))))
@ -116,8 +112,7 @@ test("goes back to the room you left when you switch spaces", async ({seed, as})
await page.getByRole("link", {name: "Other Lounge"}).click() await page.getByRole("link", {name: "Other Lounge"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "lounge")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "lounge")}$`))
// Two entries back: the other space's landing page, then the room this started on. The switch // Two entries back: the other space's landing page, then the room this started on.
// used to replace that room's entry rather than push one, so the second step overshot it.
await page.goBack() await page.goBack()
await page.goBack() await page.goBack()
@ -140,8 +135,7 @@ test("does not stack a history entry for the space you are already in", async ({
await page.getByRole("link", {name: "Space Garden"}).click() await page.getByRole("link", {name: "Space Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
// The space's entry page is the room you are on, so this navigates nowhere and should replace // The space's entry page is the room you are on, so this navigates nowhere and replaces.
// rather than push. One step back is the room this started on, not the one it never left.
await page.locator('.primary-nav [data-tip^="space"]').click() await page.locator('.primary-nav [data-tip^="space"]').click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
@ -179,10 +173,7 @@ test("takes over the space menu's history entry when you navigate out of it", as
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
await expect(drawer).toHaveCount(0) await expect(drawer).toHaveCount(0)
// The menu gave its entry back and the room pushed its own, so one step back is the room the // The content is asserted as well as the url, since a back that only rewrites the url would pass.
// menu was opened over. Stacked instead, this lands on the menu again. The room's content is
// asserted as well as the url, since a back that only rewrites the url passes every assertion
// about the address.
await page.goBack() await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
@ -216,8 +207,7 @@ test("switches spaces inside the space menu, and out of one it has a page for",
await expect(drawer.getByRole("link", {name: "Space Lounge"})).toBeVisible() await expect(drawer.getByRole("link", {name: "Space Lounge"})).toBeVisible()
// The rail is in the menu on a phone, so a space is picked with the menu still open and the room // The rail is in the menu on a phone, and navigating out from under it would close it.
// is picked after it. Navigating out from under the menu would close it on the first tap.
await drawer.locator('.primary-nav [data-tip^="other"]').click() await drawer.locator('.primary-nav [data-tip^="other"]').click()
await expect(page).toHaveURL(new RegExp(spacePath(other.url))) await expect(page).toHaveURL(new RegExp(spacePath(other.url)))
@ -228,8 +218,7 @@ test("switches spaces inside the space menu, and out of one it has a page for",
await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`))
await expect(drawer).toHaveCount(0) await expect(drawer).toHaveCount(0)
// The space she started in has a page behind it now, so picking it needs no second tap and the // The space she started in has a page behind it now, so picking it needs no second tap.
// menu has nothing left to ask.
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()
await drawer.locator('.primary-nav [data-tip^="space"]').click() await drawer.locator('.primary-nav [data-tip^="space"]').click()
@ -247,14 +236,11 @@ test("enters a space on its details page whatever its relay advertises", async (
const space = scenario.space("space") const space = scenario.space("space")
// A relay whose document claims no nip-29. The entry path used to read that as "open the chat // A relay whose document claims no nip-29. The document arrives after the first navigation.
// page", which a cold load could never get right: the document arrives after the first
// navigation, so a space opened on chat and corrected itself to about a moment later.
const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}} const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}}
const page = await as(users.alice, spacePath(space.url), {relayInfo}) const page = await as(users.alice, spacePath(space.url), {relayInfo})
await expect(page).toHaveURL(pathPattern(spacePath(space.url) + "/about")) await expect(page).toHaveURL(pathPattern(spacePath(space.url) + "/about"))
await expect(page.locator('[data-component="PageBar"]')).toContainText("Space Details")
}) })
test("builds a page once when it opens and again when its params change", async ({seed, as}) => { test("builds a page once when it opens and again when its params change", async ({seed, as}) => {
@ -301,13 +287,11 @@ test("builds a page once when it opens and again when its params change", async
page.locator("article header").getByRole("heading", {name: "Tending the Garden"}), page.locator("article header").getByRole("heading", {name: "Tending the Garden"}),
).toBeVisible() ).toBeVisible()
// The rebuild this guards against landed 20ms after the first build, which is before the article // The rebuild this guards against landed 20ms after the first build.
// itself is on screen on a slower run.
await page.waitForTimeout(250) await page.waitForTimeout(250)
await expectPageBuilds(page, 1) await expectPageBuilds(page, 1)
// Another article is the same route with different params, which SvelteKit answers by keeping the // Another article is the same route with different params, which SvelteKit answers by keeping the page.
// page it has. The page reads the address it renders once, so this one does have to be rebuilt.
await page.getByRole("link", {name: "Repotting in Winter"}).click() await page.getByRole("link", {name: "Repotting in Winter"}).click()
await expect( await expect(
@ -333,8 +317,7 @@ test("goes back to the room a profile modal was opened over", async ({seed, as})
await expect(message(page, "anyone seen the anchor")).toBeVisible() await expect(message(page, "anyone seen the anchor")).toBeVisible()
// The whole message is a button too, and its accessible name carries the author's, so the // The whole message is a button too, and its accessible name carries the author's.
// name on its own is the one that opens the profile.
await message(page, "anyone seen the anchor") await message(page, "anyone seen the anchor")
.getByRole("button", {name: "Bob Barnacle", exact: true}) .getByRole("button", {name: "Bob Barnacle", exact: true})
.click() .click()
@ -343,9 +326,7 @@ test("goes back to the room a profile modal was opened over", async ({seed, as})
await page.getByRole("button", {name: "View Full Profile"}).click() await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
// The modal gave its entry back before the profile page pushed its own, so one step back is the // The modal gave its entry back before the profile page pushed its own, so one step back is the room.
// room. Replacing that entry instead left SvelteKit with the navigation index the room already
// had, and it answered the back by writing the url without building the page again.
await page.goBack() await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))

View file

@ -5,20 +5,15 @@ import {dialog, expect, roomPath, settingToggle, test, toast, topDialog, users}
// A handle to a seeded event, which only reads once seed() has drained its queue. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string} type Seeded = {readonly id: string}
// The relay picker, which has no heading of its own — and is pushed over the list modal rather // The relay picker is pushed over the list modal, so it is the only dialog in the dom.
// than alongside it, so it is the only dialog in the dom while it is open.
const relayPicker = (page: Page) => topDialog(page) const relayPicker = (page: Page) => topDialog(page)
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name}) const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it // A toast clears after five seconds, which outlasts the batch a saved setting reaches disk in.
// 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(toast(page)).toHaveCount(0) const waitForToastToClear = (page: Page) => expect(toast(page)).toHaveCount(0)
// A Field lays its slider out under the row holding the label and the current value, so the card // A Field lays its slider out under the row holding the label and the current value.
// around both is what a slider is named from.
const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"}) const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"})
const requestSliders = (page: Page) => requestsCard(page).locator('input[type="range"]') const requestSliders = (page: Page) => requestsCard(page).locator('input[type="range"]')
@ -27,9 +22,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
// A second relay for the picker to offer. Nobody is a member of it — what puts a relay in the // A relay is offered as a suggestion once its nip-11 document has been fetched.
// 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") relay("other")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -41,12 +34,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
const page = await as(users.bob, "/settings/privacy") const page = await as(users.bob, "/settings/privacy")
const blocked = page.getByRole("button", {name: /Blocked$/}) const blocked = page.getByRole("button", {name: /Blocked$/})
await expect(blocked).toContainText("0 Blocked")
await blocked.click() await blocked.click()
await expect(dialog(page, "Blocked Relays").getByText("No relay selections found.")).toBeVisible()
await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click() await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click() await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click() await page.getByRole("button", {name: "Done"}).click()
@ -57,8 +45,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
await expect(blocked).toContainText("1 Blocked") await expect(blocked).toContainText("1 Blocked")
// A blocked relay is one bob never wants used, so it stops being offered as a suggestion. It was // It was the picker's only offer a moment ago, which is what makes its absence about the block.
// the picker's only offer a moment ago, which is what makes its absence about the block.
await blocked.click() await blocked.click()
await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click() await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
@ -78,34 +65,31 @@ test("US-086 configure alerts", async ({seed, as}) => {
const page = await as(users.alice, "/settings/alerts") const page = await as(users.alice, "/settings/alerts")
const sound = settingToggle(page, "Play sound for new activity") const sound = settingToggle(page, "Play sound for new messages")
const push = settingToggle(page, "Enable push notifications")
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")}) const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
await expect(sound).toBeChecked() 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. const reminders = alertTypes.locator("div.items-center.gap-2:has(input[type=number])")
await sound.uncheck()
await expect(alertTypes).toHaveClass(/opacity-50/) await expect(reminders).toHaveCount(1)
await expect(reminders.first().locator("input")).toHaveValue("5")
await page.getByRole("button", {name: "Discard Changes"}).click() // A reminder has to come before the event, so saving a zero is refused with an error.
await page.getByRole("button", {name: "Add reminder"}).click()
await expect(sound).toBeChecked() await reminders.nth(1).locator("input").fill("0")
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 page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Failed to request notification permissions") await expect(page.getByRole("alert")).toContainText("at least a minute")
await expect(push).not.toBeChecked()
await expect(sound).toBeChecked()
await waitForToastToClear(page) await waitForToastToClear(page)
await reminders.nth(1).locator("input").fill("1440")
await page.getByRole("button", {name: "Add reminder"}).click()
await reminders.nth(2).getByRole("button").click()
await expect(reminders).toHaveCount(2)
await sound.uncheck() await sound.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -114,12 +98,13 @@ test("US-086 configure alerts", async ({seed, as}) => {
await waitForToastToClear(page) await waitForToastToClear(page)
await page.reload() await page.reload()
await expect(settingToggle(page, "Play sound for new activity")).not.toBeChecked() await expect(settingToggle(page, "Play sound for new messages")).not.toBeChecked()
await expect(reminders).toHaveCount(2)
await expect(reminders.nth(1).locator("input")).toHaveValue("1440")
}) })
test("US-087 configure content display", async ({seed, as}) => { test("US-087 configure content display", async ({seed, as}) => {
let picture!: Seeded let picture!: Seeded
let link!: Seeded
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -129,7 +114,6 @@ test("US-087 configure content display", async ({seed, as}) => {
space.join(user.bob, "general") space.join(user.bob, "general")
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE)) 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))
space.relayList(user.alice) space.relayList(user.alice)
}) })
@ -140,9 +124,6 @@ test("US-087 configure content display", async ({seed, as}) => {
const hideSensitive = settingToggle(page, "Hide sensitive content?") const hideSensitive = settingToggle(page, "Hide sensitive content?")
const showMedia = settingToggle(page, "Show media?") const showMedia = settingToggle(page, "Show media?")
await expect(hideSensitive).toBeChecked()
await expect(showMedia).toBeChecked()
await hideSensitive.uncheck() await hideSensitive.uncheck()
await showMedia.uncheck() await showMedia.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -153,15 +134,10 @@ test("US-087 configure content display", async ({seed, as}) => {
await page.goto(roomPath(url, "general")) await page.goto(roomPath(url, "general"))
const pictureMessage = page.locator(`[data-event="${picture.id}"]`) 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.getByRole("link", {name: "images.test/sunset.png"})).toBeVisible()
await expect(pictureMessage.locator('img[src="https://images.test/sunset.png"]')).toHaveCount(0) 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 page.goto("/settings/content")
await expect(settingToggle(page, "Hide sensitive content?")).not.toBeChecked() await expect(settingToggle(page, "Hide sensitive content?")).not.toBeChecked()
@ -179,33 +155,10 @@ test("US-088 adjust send delay and media servers", async ({seed, as}) => {
const page = await as(users.alice, "/settings/content") const page = await as(users.alice, "/settings/content")
// The send delay is the only slider on this page. await page.getByLabel("Send delay").fill("3000")
const sendDelay = page.locator('input[type="range"]')
const servers = page.getByRole("listitem")
await expect(page.getByText("Delay sending messages and comments for 0 seconds.")).toBeVisible()
await sendDelay.fill("3000")
await expect(page.getByText("Delay sending messages and comments for 3 seconds.")).toBeVisible()
await expect(servers).toHaveCount(0)
await page.getByRole("button", {name: "Add Server"}).click() await page.getByRole("button", {name: "Add Server"}).click()
await page.getByRole("listitem").first().locator("input").fill("https://media.test/")
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 page.getByRole("button", {name: "Save Changes"}).click()
@ -214,8 +167,7 @@ test("US-088 adjust send delay and media servers", async ({seed, as}) => {
await waitForToastToClear(page) await waitForToastToClear(page)
await page.reload() await page.reload()
await expect(page.getByText("Delay sending messages and comments for 3 seconds.")).toBeVisible() await expect(page.getByLabel("Send delay")).toHaveValue("3000")
await expect(page.locator('input[type="range"]')).toHaveValue("3000")
}) })
test("US-089 configure privacy preferences", async ({seed, as}) => { test("US-089 configure privacy preferences", async ({seed, as}) => {
@ -233,16 +185,6 @@ test("US-089 configure privacy preferences", async ({seed, as}) => {
const auth = settingToggle(page, "Authenticate with unknown relays?") const auth = settingToggle(page, "Authenticate with unknown relays?")
const usage = settingToggle(page, "Report usage?") const usage = settingToggle(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 auth.check()
await usage.uncheck() await usage.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -267,19 +209,9 @@ test("US-129 raise the thresholds a stranger has to meet", async ({seed, as}) =>
}) })
const page = await as(users.alice, "/settings/privacy") const page = await as(users.alice, "/settings/privacy")
const pow = requestSliders(page).first()
const wot = requestSliders(page).last()
await expect(pow).toHaveValue("16") await requestSliders(page).first().fill("24")
await expect(wot).toHaveValue("3") await requestSliders(page).last().fill("1")
await expect(requestsCard(page).getByText("16 bits", {exact: true})).toBeVisible()
await expect(requestsCard(page).getByText("3 people", {exact: true})).toBeVisible()
await pow.fill("24")
await wot.fill("1")
await expect(requestsCard(page).getByText("24 bits", {exact: true})).toBeVisible()
await expect(requestsCard(page).getByText("1 person", {exact: true})).toBeVisible()
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -302,29 +234,21 @@ test("US-090 change the app's appearance", async ({seed, as}) => {
space.relayList(user.alice) space.relayList(user.alice)
}) })
const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}}) const page = await as(users.alice, "/settings/content", {context: {colorScheme: "light"}})
const body = page.locator("body") const body = page.locator("body")
const colorScheme = () =>
page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)
await expect(body).toHaveAttribute("data-theme", "light")
await expect.poll(colorScheme).toBe("light")
await page.getByRole("button", {name: "Dark", exact: true}).click() await page.getByRole("button", {name: "Dark", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "dark") await expect(body).toHaveAttribute("data-theme", "dark")
await expect.poll(colorScheme).toBe("dark")
await page.getByRole("button", {name: "System", exact: true}).click() await page.getByRole("button", {name: "System", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "light") await expect(body).toHaveAttribute("data-theme", "light")
await expect.poll(colorScheme).toBe("light")
// System means the device's, so changing the device's changes the app's. // System means the device's, so changing the device's changes the app's.
await page.emulateMedia({colorScheme: "dark"}) await page.emulateMedia({colorScheme: "dark"})
await expect(body).toHaveAttribute("data-theme", "dark") await expect(body).toHaveAttribute("data-theme", "dark")
await expect.poll(colorScheme).toBe("dark")
await page.getByLabel("Style").selectOption("navy") await page.getByLabel("Style").selectOption("navy")
@ -333,21 +257,15 @@ test("US-090 change the app's appearance", async ({seed, as}) => {
await page.reload() await page.reload()
await expect(body).toHaveAttribute("data-fl-theme", "navy") 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. // Font size is a setting, so it goes out with the rest of the page.
const fontSize = page.locator('input[type="range"]') await page.getByLabel("Font size").fill("1.25")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByText("110%")).toBeVisible() await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
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/) 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 await waitForToastToClear(page)
// without anything else having been pressed.
await page.reload() await page.reload()
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
@ -378,50 +296,15 @@ test("US-091 set up how people zap you", async ({seed, as}) => {
await address.getByPlaceholder("user@domain.com").fill("alice@example.test") await address.getByPlaceholder("user@domain.com").fill("alice@example.test")
await address.getByRole("button", {name: "Save Changes"}).click() 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 // The dialog closes itself once the profile has gone out, and the page behind it has its own Save.
// "Save Changes" — so wait it out rather than leaving that name ambiguous.
await expect(address).toHaveCount(0) await expect(address).toHaveCount(0)
await expect(page.getByText("alice@example.test")).toBeVisible() await expect(page.getByText("alice@example.test")).toBeVisible()
await expect(page.getByText("Not set")).toHaveCount(0)
await page.getByRole("button", {name: "Update"}).click() // The same utility classes land on other rows, so this is pinned to the zap form's input rows.
await address.getByPlaceholder("user@domain.com").fill("") const presets = page
await address.getByRole("button", {name: "Save Changes"}).click() .locator("form")
.filter({hasText: "Zap Amounts"})
await expect(address).toHaveCount(0) .locator("div.items-center.gap-2:has(input)")
await expect(page.getByText("Not set")).toBeVisible()
// Each preset is a row with a remove button and an amount input. The same utility classes land on
// other rows (a button's spinner), so pin it to the zap-amounts form's rows that hold an input.
const zapForm = page.locator("form").filter({hasText: "Zap Amounts"})
const presets = zapForm.locator("div.items-center.gap-2:has(input)")
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 presets.first().locator("input").fill("500")
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -431,11 +314,5 @@ test("US-091 set up how people zap you", async ({seed, as}) => {
await waitForToastToClear(page) await waitForToastToClear(page)
await page.reload() await page.reload()
const saved = page await expect(presets.first().locator("input")).toHaveValue("500")
.locator("form")
.filter({hasText: "Zap Amounts"})
.locator("div.items-center.gap-2:has(input)")
await expect(saved).toHaveCount(1)
await expect(saved.first().locator("input")).toHaveValue("500")
}) })

View file

@ -15,22 +15,12 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
const drawer = page.locator(".drawer") const drawer = page.locator(".drawer")
await expect(drawer).toHaveCount(0)
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()
await expect(drawer.getByRole("link", {name: "General"})).toBeVisible() await expect(drawer.getByRole("link", {name: "General"})).toBeVisible()
// The space rail sits beside the menu inside the panel, so the menu gets what the rail leaves. // The drawer stops short of the bottom bar, and playwright's hit-target check is what proves it.
// Sized to the panel instead, it runs off the right of the screen. await page.getByRole("button", {name: "Close space menu"}).click()
await expect(drawer.locator(".space-menu")).toBeInViewport({ratio: 1})
// The panel is full width, so the only way back out is the bottom bar, which the drawer stops
// short of rather than covering. Playwright's hit-target check is what proves it: a drawer over
// the bar would take the click itself.
const closeButton = page.getByRole("button", {name: "Close space menu"})
await closeButton.click()
await expect(drawer).toHaveCount(0) await expect(drawer).toHaveCount(0)
@ -43,8 +33,7 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
await expect(drawer).toHaveCount(0) await expect(drawer).toHaveCount(0)
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page // The button is the bottom bar's rather than the page's, so it opens on the last space visited.
// that is in no space at all, on the last space the reader was in.
await page.goto("/chat") await page.goto("/chat")
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()

View file

@ -1,12 +1,10 @@
import {DAY, HOUR, WEEK, sleep, sortBy} from "@welshman/lib" import {DAY, HOUR, sleep, sortBy} from "@welshman/lib"
import {ROOMS} from "@welshman/util" import {ROOMS} from "@welshman/util"
import type {Page} from "@playwright/test" import type {Page} from "@playwright/test"
import type {SeededSpace} from "../harness" import type {SeededSpace} from "../harness"
import {expect, pathPattern, readCachedEvents, roomPath, spacePath, test, users} from "../harness" import {expect, pathPattern, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
// The space this user's room list names first, read from the copy on disk the app restores itself // Room lists reach indexeddb in three-second batches, so a spec about a reload waits on this first.
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
// has landed, so a spec about what survives a reload waits on this before it reloads.
const cachedFirstSpace = async (page: Page, pubkey: string) => { const cachedFirstSpace = async (page: Page, pubkey: string) => {
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS) const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
const newest = sortBy(event => -event.created_at, events)[0] const newest = sortBy(event => -event.created_at, events)[0]
@ -14,8 +12,7 @@ const cachedFirstSpace = async (page: Page, pubkey: string) => {
return newest?.tags.find(tag => tag[0] === "r")?.[1] return newest?.tags.find(tag => tag[0] === "r")?.[1]
} }
// The rail shows icons and no text, so a row is read by the tooltip naming its relay. See // The rail shows icons and no text, so a row is read by the tooltip naming its relay.
// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name.
const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]") const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]")
const expectRailFirst = (page: Page, name: string) => const expectRailFirst = (page: Page, name: string) =>
@ -24,11 +21,7 @@ const expectRailFirst = (page: Page, name: string) =>
new RegExp(`^${name}`), new RegExp(`^${name}`),
) )
// A drop reorders the list in place and publishes a new room list behind it, so the order on screen // A drop reorders the list in place and publishes behind it, so the copy on disk is the round trip.
// is ahead of the one the app has settled on. Reading the next drag off that optimistic order is
// what made this spec fail under a full suite and pass alone: the relay is slower when the box is
// busy, and a room list landing back from it after the next drop replaces the order that drop
// applied. Waiting for the copy on disk to name the new first space is waiting for the round trip.
const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => { const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
await expectRailFirst(page, space.name) await expectRailFirst(page, space.name)
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url) await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url)
@ -57,49 +50,30 @@ test("US-009 browse and search spaces, and reorder your own", async ({seed, as})
const other = scenario.space("other") const other = scenario.space("other")
const unsigned = scenario.space("unsigned") const unsigned = scenario.space("unsigned")
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it // The spaces page discovers unjoined spaces by pulling the room lists of its bootstrap pubkeys.
// 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}}) const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
// The page is for spaces she hasn't joined. The ones she has are in the rail, all of them. // The page is for spaces she hasn't joined. The ones she has are in the rail.
await expect(page.getByText("Browse Spaces")).toBeVisible()
await expect(page.getByText(unsigned.url)).toBeVisible() await expect(page.getByText(unsigned.url)).toBeVisible()
await expect(page.getByText(space.url)).toHaveCount(0) await expect(page.getByText(space.url)).toHaveCount(0)
await expect(page.getByText(other.url)).toHaveCount(0)
await expect(railSpaces(page)).toHaveCount(2)
const term = page.getByPlaceholder("Search for spaces...") const term = page.getByPlaceholder("Search for spaces...")
await term.fill("unsigned")
await expect(page.getByText(unsigned.url)).toBeVisible()
await term.fill("nothing by that name") await term.fill("nothing by that name")
await expect(page.getByText(unsigned.url)).toHaveCount(0) await expect(page.getByText(unsigned.url)).toHaveCount(0)
await term.fill("") await term.fill("unsigned")
// A space she hasn't joined asks her to join first
await page.getByRole("button").filter({hasText: unsigned.url}).click() await page.getByRole("button").filter({hasText: unsigned.url}).click()
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible() await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
// A space she has joined opens from the rail
await page.goto("/spaces")
await railSpaces(page).first().click()
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
// Reordering by dragging, which lives in her room list and so outlives the page // Reordering by dragging, which lives in her room list and so outlives the page
await page.goto("/spaces") await page.goto("/spaces")
await expectRailFirst(page, space.name) await expectRailFirst(page, space.name)
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag // Chromium's synthetic drag starts and moves it but never delivers the drop the reorder needs.
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
// row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer()) const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
const rail = railSpaces(page) const rail = railSpaces(page)
@ -127,32 +101,19 @@ test("US-010 join a space from an invite link", async ({seed, as}) => {
const page = await as(users.alice, spacePath(other.url)) const page = await as(users.alice, spacePath(other.url))
// Arriving at a space she hasn't joined raises the join prompt on its own // 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 expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
await page.goto("/spaces") await page.goto("/spaces")
await page.getByRole("button", {name: "Add Space"}).click() await page.getByRole("button", {name: "Add Space"}).click()
await page.getByRole("button", {name: /Join a space/}).click() await page.getByRole("button", {name: /Join a space/}).click()
const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox") // An absolute platform url is an off-origin navigation, and parseInviteLink reads query params.
await page
.locator("form")
.filter({hasText: "Invite Link*"})
.getByRole("textbox")
.fill("https://app.flotilla.social/join?r=other.test&c=")
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 expect(page.getByText(other.url)).toBeVisible()
await page.getByRole("button", {name: "Join Space"}).click() await page.getByRole("button", {name: "Join Space"}).click()
@ -174,9 +135,7 @@ test("US-010 a direct link's join prompt outlives the entry redirect", async ({s
const other = scenario.space("other") const other = scenario.space("other")
const page = await as(users.alice, "/") const page = await as(users.alice, "/")
// A space's own path redirects to the page it opens on, and a modal raised while that navigation // A space's own path redirects, and a modal raised while that navigation is in flight is lost.
// is in flight is overwritten when it lands. Holding the entry page's module back is what puts
// the prompt inside the redirect here; on a phone the chunk arriving late does it by itself.
let held = 0 let held = 0
await page.context().route( await page.context().route(
@ -193,7 +152,6 @@ test("US-010 a direct link's join prompt outlives the entry redirect", async ({s
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled({timeout: 60000}) await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled({timeout: 60000})
await expect(page).toHaveURL(pathPattern(spacePath(other.url) + "/about")) await expect(page).toHaveURL(pathPattern(spacePath(other.url) + "/about"))
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
// A page that stops asking for that module is a spec that no longer covers the race. // A page that stops asking for that module is a spec that no longer covers the race.
expect(held).toBeGreaterThan(0) expect(held).toBeGreaterThan(0)
@ -204,9 +162,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
const closed = relay("closed") const closed = relay("closed")
const space = relay("space") const space = relay("space")
// This relay refuses a join that carries no claim. A claim is not an event a scenario can // The relay only registers a claim through the nip-86 method the invite dialog calls.
// 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"}) closed.room("lobby", {name: "Lobby"})
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -218,8 +174,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
const closed = scenario.space("closed") const closed = scenario.space("closed")
const space = scenario.space("space") const space = scenario.space("space")
// Administering a relay is not the same as belonging to it, so the space asks admin to join // Administering a relay is not the same as belonging to it.
// like anyone else — and issues him an invite code all the same.
const admin = await as(users.admin, spacePath(closed.url) + "/about") const admin = await as(users.admin, spacePath(closed.url) + "/about")
await admin.locator("form").getByRole("button", {name: "Go back"}).click() await admin.locator("form").getByRole("button", {name: "Go back"}).click()
@ -235,12 +190,8 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
const alice = await as(users.alice, spacePath(closed.url)) const alice = await as(users.alice, spacePath(closed.url))
// Turned away rather than joined, so the prompt becomes a request // 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 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") const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox")
await code.fill("NOTTHECODE") await code.fill("NOTTHECODE")
@ -248,7 +199,6 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
// The relay refused the code, so she is told and left asking // 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("alert")).toContainText("join request rejected")
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
await code.fill(claim) await code.fill(claim)
await alice.getByRole("button", {name: "Join Space"}).click() await alice.getByRole("button", {name: "Join Space"}).click()
@ -272,8 +222,6 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
await bobCard.getByRole("button").last().click() await bobCard.getByRole("button").last().click()
await admin.getByRole("button", {name: "Ban member"}).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 admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByText("Member has successfully been banned!")).toBeVisible() await expect(admin.getByText("Member has successfully been banned!")).toBeVisible()
@ -281,23 +229,10 @@ test("US-011 request access when a space turns you away", async ({seed, as}) =>
const bob = await as(users.bob, spacePath(space.url)) const bob = await as(users.bob, spacePath(space.url))
await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible() 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 bob.getByRole("button", {name: "Leave Space"}).click()
await expect(bob).toHaveURL(/\/home/) await expect(bob).toHaveURL(/\/home/)
await bob.goto("/spaces")
await expect(railSpaces(bob)).toHaveCount(0)
}) })
test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => { test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
@ -321,8 +256,7 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
await expect(bob).toHaveURL(/\/home/) await expect(bob).toHaveURL(/\/home/)
// In-app rather than a fresh load: leaving the space is published in the background, and a // In-app rather than a fresh load: leaving is published in the background and reaches disk late.
// page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click() await bob.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(bob)).toHaveCount(0) await expect(railSpaces(bob)).toHaveCount(0)
@ -358,21 +292,14 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}} const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}}
const alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo}) 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 alice.getByRole("button", {name: "Update and go"}).click()
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/) await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
// In-app rather than a fresh load: the updated list is published in the background, and a page // In-app rather than a fresh load: the updated list is published in the background.
// that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click() await alice.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(alice)).toHaveCount(1)
await expectRailFirst(alice, other.name) await expectRailFirst(alice, other.name)
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo}) const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo})
@ -382,11 +309,6 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
await bob.getByRole("button", {name: "Not now"}).click() await bob.getByRole("button", {name: "Not now"}).click()
await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/) await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/)
await bob.goto("/spaces")
await expect(railSpaces(bob)).toHaveCount(1)
await expectRailFirst(bob, space.name)
}) })
test("US-014 leave a space", async ({seed, as}) => { test("US-014 leave a space", async ({seed, as}) => {
@ -404,33 +326,14 @@ test("US-014 leave a space", async ({seed, as}) => {
await page.getByRole("button", {name: /space\.test/}).click() await page.getByRole("button", {name: /space\.test/}).click()
await page.getByRole("button", {name: "Leave Space"}).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 page.getByRole("button", {name: "Confirm"}).click()
await expect(page).toHaveURL(/\/home/) await expect(page).toHaveURL(/\/home/)
// In-app rather than a fresh load: leaving is published in the background, and a page that // In-app rather than a fresh load: leaving is published in the background.
// reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click() await page.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(page)).toHaveCount(0) await expect(railSpaces(page)).toHaveCount(0)
// 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}) => { test("US-015 view a space's details", async ({seed, as}) => {
@ -446,38 +349,20 @@ test("US-015 view a space's details", async ({seed, as}) => {
const space = scenario.space("space") const space = scenario.space("space")
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish // Contact and terms are nip-11 fields zooid doesn't publish.
// on its own, so the scenario merges them over the relay's real document.
const relayInfo = { const relayInfo = {
[space.url]: { [space.url]: {
icon: "https://space.test/icon.png",
contact: "harbormaster@space.test", contact: "harbormaster@space.test",
terms_of_service: "https://space.test/terms", 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}) 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.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.getByText(/Throwaway relay/)).toBeVisible()
await expect(carol.getByText(/harbormaster@space\.test/)).toBeVisible()
await expect(carol.getByRole("link", {name: "Terms of Service"})).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()
const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo}) const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo})
const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..") const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..")
@ -492,10 +377,6 @@ test("US-015 view a space's details", async ({seed, as}) => {
// What admin featured reaches every visitor // What admin featured reaches every visitor
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible() await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
await expect(carol.getByText("Start with the harbor rules")).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-017 search across a space", async ({seed, as}) => { test("US-017 search across a space", async ({seed, as}) => {
@ -507,13 +388,11 @@ test("US-017 search across a space", async ({seed, as}) => {
space.join(user.alice, "general", "random") space.join(user.alice, "general", "random")
space.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR)) 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, "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") const space = scenario.space("space")
// The directory rather than a feed: search is reachable from every page in a space, and this is // The directory rather than a feed, since a result's own text can't be on the page behind it.
// 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") const page = await as(users.alice, spacePath(space.url) + "/directory")
await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click() await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click()
@ -526,18 +405,9 @@ test("US-017 search across a space", async ({seed, as}) => {
await term.fill("kraken") 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("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 page.getByRole("button").filter({hasText: "kraken jokes only in here"}).click()
await expect(fromRandom).toContainText("Random")
await fromRandom.click()
await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/) await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/)
}) })

View file

@ -133,12 +133,12 @@ createCapacitorElectronApp({
csp: { csp: {
// SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap. // SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap.
policy: "base-uri 'self'; object-src 'none'", policy: "base-uri 'self'; object-src 'none'",
// Capawesome 0.1.1's dev policy, plus Flotilla's analytics origin and blob workers. // Capawesome 0.1.1's dev policy, plus Flotilla's analytics origin, blob workers and map tiles.
devPolicy: [ devPolicy: [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social",
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:", "img-src 'self' data: blob: https://dufflepud.coracle.social",
"font-src 'self' data:", "font-src 'self' data:",
"media-src 'self' blob:", "media-src 'self' blob:",
"connect-src 'self' https: wss: ws: http:", "connect-src 'self' https: wss: ws: http:",

View file

@ -0,0 +1,9 @@
* Open notes in flotilla
* Split the DM list into conversations and requests
* Redesign the goal list and detail pages
* List a space's admins and edit a member's permissions
* Let any member curate the space library, and add an option to let members invite
* Add native app shortcuts for common features
* Review what a health check will do before applying it
* Move the key backup reminder out of onboarding
* Simplify hosting rows on the home page

View file

@ -0,0 +1,6 @@
* Offer a way out when startup gets stuck waiting on a remote signer
* Sign out of a remote signer session whose app data was cleared, instead of stalling at startup
* Show a room's messages before its space finishes syncing
* Fix messages missing from feeds when some relays answer more slowly than others
* Add an F-Droid build, signed with the same key as the other APKs
* Keep GitHub releases up to date for Obtainium

View file

@ -0,0 +1,3 @@
Flotilla is a Nostr client for community chat, in the style of Discord. Each space is a relay, and its rooms are NIP-29 groups inside it. It also has encrypted direct messages, threads, a calendar, classifieds and goals.
This build leaves out Firebase, Google Services and analytics. Instead of push notifications it checks your relays in the background about every 15 minutes, and Android may delay that further.

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View file

@ -0,0 +1 @@
Digital communities for free people

View file

@ -0,0 +1 @@
Flotilla

View file

@ -21,8 +21,8 @@ with the Node, pnpm, Java, Android SDK, and Gradle versions pinned by this repos
```sh ```sh
corepack enable corepack enable
./scripts/prepare-fdroid-source.sh ./scripts/fdroid/prepare.sh
./scripts/build-fdroid-assets.sh ./scripts/fdroid/build.sh
cd android cd android
./gradlew assembleFdroidRelease ./gradlew assembleFdroidRelease
``` ```
@ -41,28 +41,31 @@ The unsigned APK is written to
F-Droid should run preparation before its source scan, run the asset build afterward, F-Droid should run preparation before its source scan, run the asset build afterward,
and use its configured Gradle runner for `assembleFdroidRelease`. and use its configured Gradle runner for `assembleFdroidRelease`.
The F-Droid recipe can use: [`metadata/social.flotilla.fdroid.yml`](metadata/social.flotilla.fdroid.yml) is the recipe to submit
to `fdroiddata`. The listing's text, icon, feature graphic and per-version changelogs come from
`fastlane/metadata/android/en-US/` at the tag F-Droid builds. Preparation installs dependencies
before F-Droid's source scan, so the recipe scan-ignores `node_modules`, which holds FLOSS build
tools such as esbuild and sharp. The recipe installs JDK 21, which Capacitor needs, and Node from
nodejs.org at a pinned checksum, as a `.tar.gz` since the build server has no `xz`.
```yaml ## Reproducible builds
subdir: android/app
gradle: F-Droid rebuilds each tag, downloads the apk at the recipe's `Binaries` url, and ships that apk
- fdroid instead of its own when copying its signature onto F-Droid's build verifies. So the F-Droid app
prebuild: carries the distribution key, the one `AllowedAPKSigningKeys` names, and can never switch to
- ../../scripts/prepare-fdroid-source.sh F-Droid's key.
build:
- ../../scripts/build-fdroid-assets.sh The release workflow's `fdroid` step runs `scripts/fdroid/reproduce.sh` in F-Droid's
``` `buildserver-trixie` image, the way fdroiddata's own CI builds a recipe, against the recipe in
`metadata/` pointed at the tag. It uploads the unsigned apk to the `flotilla-fdroid` generic
package on gitea, and `pnpm release:local fdroid-sign` signs it and uploads the result beside it.
The recipe in `metadata/` is the one both builds read, so a change to it goes to `fdroiddata` too.
## Updates ## Updates
Stable releases use bare version tags such as `1.9.1`. Proposed update metadata: Stable releases use bare version tags such as `1.9.1`, and the recipe checks tags matching
`^[0-9]+\.[0-9]+\.[0-9]+$` against `versionCode` and `versionName` in
```yaml `android/app/build.gradle` at that tag.
UpdateCheckMode: Tags ^[0-9]+\.[0-9]+\.[0-9]+$
UpdateCheckData: 'android/app/build.gradle|(?m)^\s*versionCode\s+(\d+)\s*$|.|(?m)^\s*versionName\s+"([^"]+)"\s*$'
AutoUpdateMode: Version
```
The initial `fdroiddata` submission must still review scanner exceptions for FLOSS The initial `fdroiddata` submission must still review scanner exceptions for FLOSS
build tools, optional OpenRouter use for a possible `NonFreeNet` declaration, and build tools and optional OpenRouter use for a possible `NonFreeNet` declaration.
the final F-Droid signing certificate for Android App Links.

View file

@ -0,0 +1,67 @@
Categories:
- Internet
License: MIT
AuthorName: Jon Staab
AuthorWebSite: https://flotilla.social
WebSite: https://app.flotilla.social
SourceCode: https://gitea.coracle.social/coracle/flotilla
IssueTracker: https://gitea.coracle.social/coracle/flotilla/issues
Changelog: https://gitea.coracle.social/coracle/flotilla/src/branch/master/CHANGELOG.md
AutoName: Flotilla
RepoType: git
Repo: https://gitea.coracle.social/coracle/flotilla.git
Binaries:
https://gitea.coracle.social/api/packages/coracle/generic/flotilla-fdroid/%v/flotilla-fdroid-%v.apk
Builds:
- versionName: 1.11.2
versionCode: 55
commit: 95cbb9b6f736df21199185ac4c8dc5c841e2c1b1
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-21-jdk-headless
- update-alternatives --auto java
- curl -Lo node.tar.gz https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-x64.tar.gz
- echo "1084aa36196bba4c3a5e69a1ee388a6e4ff729dad09445fbcd434b28fe3c24af node.tar.gz"
| sha256sum -c -
- tar xzf node.tar.gz --strip-components=1 -C /usr/local/
- rm node.tar.gz
- corepack enable
gradle:
- fdroid
prebuild:
- cd ../..
- V='version:{name:process.env.VITE_BUILD_HASH},'
- F=svelte.config.js
- grep -q BUILD_HASH $F || sed -i "/serviceWorker:/i $V" $F
- scripts/fdroid/prepare.sh
scanignore:
- node_modules
build: ../../scripts/fdroid/build.sh
AllowedAPKSigningKeys: 6daf683e1ca83a4cd88573e9739e2aa944c85d56154e344230557cffed4ad78c
MaintainerNotes: |-
scripts/fdroid/prepare.sh removes Firebase, Google Services, Plausible and the ACINQ
native secp256k1 library, then installs dependencies, so it runs as prebuild ahead of
the scanner. node_modules holds FLOSS build tools (esbuild, sharp), hence scanignore.
Capacitor needs JDK 21, and Node comes from nodejs.org at a pinned checksum to match
the lts/jod in .nvmrc.
Builds are reproducible: upstream's release workflow runs this recipe in the
buildserver-trixie image (scripts/fdroid/reproduce.sh) and publishes that apk, signed
with the distribution key, at Binaries.
Tags before 1.11.3 version the SvelteKit build by build time, which no two builds share,
so prebuild sets it from the commit instead. Later tags do that themselves, and the
patch skips them.
AutoUpdateMode: Version
UpdateCheckMode: Tags ^[0-9]+\.[0-9]+\.[0-9]+$
UpdateCheckData:
android/app/build.gradle|(?m)^\s*versionCode\s+(\d+)\s*$|.|(?m)^\s*versionName\s+"([^"]+)"\s*$
CurrentVersion: 1.11.2
CurrentVersionCode: 55

View file

@ -13,7 +13,7 @@
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; }; 3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; A1F0C0DE2E90000100005CE1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F0C0DE2E90000200005CE2 /* SceneDelegate.swift */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
@ -56,7 +56,7 @@
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; A1F0C0DE2E90000200005CE2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -131,7 +131,7 @@
children = ( children = (
50379B222058CBB4000EE86E /* capacitor.config.json */, 50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */, 504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */, A1F0C0DE2E90000200005CE2 /* SceneDelegate.swift */,
504EC30E1FED79650016851F /* Assets.xcassets */, 504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */, 504EC3131FED79650016851F /* Info.plist */,
@ -251,7 +251,6 @@
50B271D11FEDC1A000F3C39B /* public in Resources */, 50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */, 504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */, 2FAD9763203C412B000D30F8 /* config.xml in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -308,6 +307,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
A1F0C0DE2E90000100005CE1 /* SceneDelegate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -322,14 +322,6 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
children = ( children = (
@ -350,7 +342,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 45;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@ -362,7 +354,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.11.0; MARKETING_VERSION = 1.11.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
@ -387,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 45;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@ -399,7 +391,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.11.0; MARKETING_VERSION = 1.11.2;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -541,7 +533,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 45;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -550,7 +542,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.11.0; MARKETING_VERSION = 1.11.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -570,7 +562,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 45;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -579,7 +571,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.11.0; MARKETING_VERSION = 1.11.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1,88 +1,21 @@
import UIKit import UIKit
import Capacitor import Capacitor
@UIApplicationMain @main
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem,
let url = shortcutURL(for: shortcutItem.type) {
// The proxy retains lastURL for App.getLaunchUrl() before the bridge is ready.
_ = ApplicationDelegateProxy.shared.application(application, open: url, options: [:])
return false
}
// Override point for customization after application launch.
return true return true
} }
func application( func application(
_ application: UIApplication, _ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem, configurationForConnecting connectingSceneSession: UISceneSession,
completionHandler: @escaping (Bool) -> Void options: UIScene.ConnectionOptions
) { ) -> UISceneConfiguration {
guard let url = shortcutURL(for: shortcutItem.type) else { let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
completionHandler(false) config.delegateClass = SceneDelegate.self
return return config
}
completionHandler(ApplicationDelegateProxy.shared.application(application, open: url, options: [:]))
}
private func shortcutURL(for type: String) -> URL? {
let path: String
switch type {
case "social.flotilla.shortcut.messages":
path = "messages"
case "social.flotilla.shortcut.search":
path = "search"
case "social.flotilla.shortcut.spaces":
path = "spaces"
case "social.flotilla.shortcut.inbox":
path = "inbox"
default:
return nil
}
return URL(string: "flotilla://shortcut/\(path)")
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View file

@ -80,11 +80,26 @@
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIApplicationSceneManifest</key>
<string>Main</string> <dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
<array> <array>
<string>armv7</string> <string>arm64</string>
</array> </array>
<key>UIStatusBarStyle</key> <key>UIStatusBarStyle</key>
<string></string> <string></string>

View file

@ -0,0 +1,58 @@
import UIKit
import Capacitor
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = CAPBridgeViewController()
window?.makeKeyAndVisible()
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
if let shortcutItem = connectionOptions.shortcutItem {
// The proxy retains lastURL for App.getLaunchUrl() before the bridge is ready.
_ = openShortcut(shortcutItem)
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(openShortcut(shortcutItem))
}
private func openShortcut(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
let path: String
switch shortcutItem.type {
case "social.flotilla.shortcut.messages":
path = "messages"
case "social.flotilla.shortcut.search":
path = "search"
case "social.flotilla.shortcut.spaces":
path = "spaces"
case "social.flotilla.shortcut.inbox":
path = "inbox"
default:
return false
}
guard let url = URL(string: "flotilla://shortcut/\(path)") else { return false }
return ApplicationDelegateProxy.shared.application(UIApplication.shared, open: url, options: [:])
}
}

View file

@ -1,4 +1,4 @@
require_relative '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios/scripts/pods_helpers' require_relative '../../node_modules/.pnpm/@capacitor+ios@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '15.0' platform :ios, '15.0'
use_frameworks! use_frameworks!
@ -9,18 +9,18 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/ios'
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.4/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.2/node_modules/@capacitor/app'
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/clipboard' pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.4/node_modules/@capacitor/filesystem' pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.3_@capacitor+core@8.5.2/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.4/node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.5_@capacitor+core@8.5.2/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/preferences' pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications' pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.1.2_@capacitor+core@8.5.2/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/share' pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.2_@capacitor+core@8.5.2/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-badge' pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.5.2/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_fd4b5c957724da9f45e5678eca5c7fd4/node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_46ef56d5c94707ead7edfcb859900e71/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do

View file

@ -1,36 +1,41 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.11.0", "version": "1.11.2",
"author": { "author": {
"name": "Jon Staab" "name": "Jon Staab"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "./scripts/build.sh", "build": "./scripts/build/app.sh",
"build:desktop": "bash scripts/build-desktop.sh", "build:desktop": "bash scripts/desktop/build.sh",
"dev:desktop": "node scripts/dev-desktop.mjs", "dev:desktop": "node scripts/desktop/dev.mjs",
"start:desktop": "npm --prefix electron start", "start:desktop": "npm --prefix electron start",
"build:server": "vite build --config vite.config.server.ts", "build:server": "vite build --config vite.config.server.ts",
"start": "node server.js", "start": "node server.js",
"release": "node scripts/release.mjs", "release:check": "node scripts/release/local.mjs --check && node scripts/release/ci.mjs --check",
"bump": "node scripts/bump-version.mjs", "release:changelog": "node scripts/release/changelog.mjs",
"release:local": "node scripts/release/local.mjs",
"release:ci": "node scripts/release/ci.mjs",
"bump": "node scripts/release/bump.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/release.mjs scripts/release/*.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts", "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && eslint src e2e packages fdroid capacitor.config.ts vite.config.server.ts server.js server electron/*.ts electron/electron-builder.config.mjs scripts",
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
"test": "playwright test", "test": "playwright test",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:ui": "playwright test --ui", "test:ui": "playwright test --ui",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:server": "node --test --test-force-exit server/test/*.test.mjs",
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src", "format:all": "prettier --write src",
"prepare": "husky", "prepare": "husky",
"package:desktop:linux": "node scripts/package-desktop.mjs linux", "package:desktop:linux": "node scripts/desktop/package.mjs linux",
"package:desktop:windows": "node scripts/package-desktop.mjs windows", "package:desktop:windows": "node scripts/desktop/package.mjs windows",
"package:desktop:macos": "node scripts/package-desktop.mjs macos" "package:desktop:macos": "node scripts/desktop/package.mjs macos"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.5.2",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/kit": "^2.61.1", "@sveltejs/kit": "^2.61.1",
@ -61,17 +66,16 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.1.1",
"@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1", "@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1", "@capacitor/core": "^8.5.2",
"@capacitor/filesystem": "^8.1.0", "@capacitor/filesystem": "^8.1.3",
"@capacitor/ios": "^8.0.1", "@capacitor/ios": "^8.5.2",
"@capacitor/keyboard": "^8.0.0", "@capacitor/keyboard": "^8.0.5",
"@capacitor/preferences": "^8.0.0", "@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0", "@capacitor/push-notifications": "^8.1.2",
"@capacitor/share": "^8.0.1", "@capacitor/share": "^8.0.2",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0", "@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0",
"@capawesome/capacitor-electron": "0.1.1", "@capawesome/capacitor-electron": "0.1.1",
@ -89,20 +93,21 @@
"@tiptap/core": "^2.27.3", "@tiptap/core": "^2.27.3",
"@tiptap/pm": "^2.27.3", "@tiptap/pm": "^2.27.3",
"@tiptap/suggestion": "^2.27.3", "@tiptap/suggestion": "^2.27.3",
"@types/leaflet": "^1.9.22",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.11.0", "@welshman/app": "^0.12.3",
"@welshman/content": "^0.11.0", "@welshman/content": "^0.12.3",
"@welshman/domain": "^0.11.0", "@welshman/domain": "^0.12.3",
"@welshman/editor": "^0.11.0", "@welshman/editor": "^0.12.3",
"@welshman/feeds": "^0.11.0", "@welshman/feeds": "^0.12.3",
"@welshman/lib": "^0.11.0", "@welshman/lib": "^0.12.3",
"@welshman/net": "^0.11.0", "@welshman/net": "^0.12.3",
"@welshman/signer": "^0.11.0", "@welshman/signer": "^0.12.3",
"@welshman/store": "^0.11.0", "@welshman/store": "^0.12.3",
"@welshman/util": "^0.11.0", "@welshman/util": "^0.12.3",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13", "dompurify": "^3.4.13",
@ -114,6 +119,7 @@
"hono": "^4.12.23", "hono": "^4.12.23",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"leaflet": "^1.9.4",
"livekit-client": "^2.17.2", "livekit-client": "^2.17.2",
"marked": "^18.0.9", "marked": "^18.0.9",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",

Binary file not shown.

View file

@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -e
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags || true
git describe --tags --abbrev=0 || true
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
# The build runs out of memory at times
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
source ./scripts/build-web.sh source ./scripts/build/web.sh
npx cap sync npx cap sync
npx @capacitor/assets generate \ npx @capacitor/assets generate \
@ -10,4 +10,4 @@ npx @capacitor/assets generate \
--splashBackgroundColorDark '#191E24' --splashBackgroundColorDark '#191E24'
# @capacitor/assets doesn't generate Android notification icons # @capacitor/assets doesn't generate Android notification icons
node scripts/generate-notification-icon.mjs node scripts/build/notification-icon.mjs

View file

@ -4,7 +4,7 @@ set -e
export FLOTILLA_DESKTOP=1 export FLOTILLA_DESKTOP=1
export NODE_ENV=production export NODE_ENV=production
unset FLOTILLA_DESKTOP_DEV_URL CAPACITOR_ELECTRON_DEV_SERVER_URL unset FLOTILLA_DESKTOP_DEV_URL CAPACITOR_ELECTRON_DEV_SERVER_URL
source ./scripts/build-web.sh source ./scripts/build/web.sh
export VITE_PLATFORM_NAME export VITE_PLATFORM_NAME
# Capacitor sync swallows copy failures; its rejection handler also leaves exit 0. # Capacitor sync swallows copy failures; its rejection handler also leaves exit 0.

View file

@ -3,7 +3,7 @@ import {createRequire} from "node:module"
import {dirname, resolve} from "node:path" import {dirname, resolve} from "node:path"
import {fileURLToPath} from "node:url" import {fileURLToPath} from "node:url"
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const windows = process.platform === "win32" const windows = process.platform === "win32"
let server let server

View file

@ -5,10 +5,14 @@ import {fileURLToPath} from "node:url"
import sharp from "sharp" import sharp from "sharp"
import {loadEnv} from "vite" import {loadEnv} from "vite"
const root = fileURLToPath(new URL("../", import.meta.url)) const root = fileURLToPath(new URL("../../", import.meta.url))
const [target, option, ...rest] = process.argv.slice(2) const [target, option, ...rest] = process.argv.slice(2)
const platforms = {linux: "--linux", windows: "--win", macos: "--mac"} const platforms = {linux: "--linux", windows: "--win", macos: "--mac"}
const env = {...process.env, ...loadEnv("production", root, "VITE_"), NODE_ENV: "production"} const env = {
...process.env,
...loadEnv("production", root, ["VITE_", "DOCKER"]),
NODE_ENV: "production",
}
const run = (command, args, options = {}) => const run = (command, args, options = {}) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@ -25,7 +29,7 @@ const run = (command, args, options = {}) =>
try { try {
if (!Object.hasOwn(platforms, target) || rest.length || (option && option !== "--dir")) { if (!Object.hasOwn(platforms, target) || rest.length || (option && option !== "--dir")) {
throw new Error("Usage: node scripts/package-desktop.mjs linux|windows|macos [--dir]") throw new Error("Usage: node scripts/desktop/package.mjs linux|windows|macos [--dir]")
} }
delete env.FLOTILLA_DESKTOP_DEV_URL delete env.FLOTILLA_DESKTOP_DEV_URL
delete env.CAPACITOR_ELECTRON_DEV_SERVER_URL delete env.CAPACITOR_ELECTRON_DEV_SERVER_URL
@ -44,7 +48,7 @@ try {
env.VITE_PLATFORM_LOGO = "static/desktop-logo.png" env.VITE_PLATFORM_LOGO = "static/desktop-logo.png"
await sharp(logo).resize(1024, 1024).png().toFile(join(root, env.VITE_PLATFORM_LOGO)) await sharp(logo).resize(1024, 1024).png().toFile(join(root, env.VITE_PLATFORM_LOGO))
await run("bash", ["scripts/build-desktop.sh"]) await run("bash", ["scripts/desktop/build.sh"])
await cp(join(root, env.VITE_PLATFORM_LOGO), join(root, "electron/generated/icon.png")) await cp(join(root, env.VITE_PLATFORM_LOGO), join(root, "electron/generated/icon.png"))
await cp(join(root, "static/favicon.ico"), join(root, "electron/generated/icon.ico")) await cp(join(root, "static/favicon.ico"), join(root, "electron/generated/icon.ico"))
for (const [size, name] of [ for (const [size, name] of [
@ -70,15 +74,18 @@ try {
// --dir replaces configured targets, including their architectures. // --dir replaces configured targets, including their architectures.
args.push("--x64", "--arm64") args.push("--x64", "--arm64")
} }
if (target === "windows" && process.platform === "linux") { if (
(target === "windows" && process.platform !== "win32") ||
(target === "linux" && process.platform !== "linux")
) {
const files = await readdir(join(root, "electron/vendor"), {recursive: true}) const files = await readdir(join(root, "electron/vendor"), {recursive: true})
if (files.some(file => file.endsWith(".node"))) { if (files.some(file => file.endsWith(".node"))) {
throw new Error( throw new Error(`Native Electron addons require a rebuild before packaging for ${target}`)
"Native Electron addons require a Windows ABI rebuild before packaging on Windows",
)
} }
await mkdir(join(root, "electron/dist"), {recursive: true}) await mkdir(join(root, "electron/dist"), {recursive: true})
const directory = await mkdtemp(join(root, "electron/dist/package-")) const directory = await mkdtemp(join(root, "electron/dist/package-"))
const docker = env.DOCKER || "docker"
const container = `flotilla-package-${process.pid}`
try { try {
await cp(join(root, "package.json"), join(directory, "package.json")) await cp(join(root, "package.json"), join(directory, "package.json"))
for (const file of [ for (const file of [
@ -92,13 +99,14 @@ try {
]) { ]) {
await cp(join(root, "electron", file), join(directory, "electron", file), {recursive: true}) await cp(join(root, "electron", file), join(directory, "electron", file), {recursive: true})
} }
await run("docker", [ // Copied in and out rather than mounted: a CI job that shares the host's docker socket
"run", // would mount the host's path, not its own
"--rm", await run(docker, [
"create",
"--name",
container,
"--platform", "--platform",
"linux/amd64", "linux/amd64",
"--volume",
`${directory}:/project:Z`,
"--workdir", "--workdir",
"/project/electron", "/project/electron",
"--env", "--env",
@ -108,14 +116,19 @@ try {
"electronuserland/builder@sha256:41ae540902461b6cbc988987db79547fcc10cda04d2a6c6367504f59d4b37c64", "electronuserland/builder@sha256:41ae540902461b6cbc988987db79547fcc10cda04d2a6c6367504f59d4b37c64",
"bash", "bash",
"-c", "-c",
'owner=$1; group=$2; shift 2; npm ci --ignore-scripts && npm run pack -- "$@"; result=$?; chown -R "$owner:$group" /project; exit "$result"', 'npm ci --ignore-scripts && npm run pack -- "$@"',
"--", "--",
String(process.getuid()),
String(process.getgid()),
...args, ...args,
]) ])
await cp(join(directory, "electron/dist"), join(root, "electron/dist"), {recursive: true}) await run(docker, ["cp", `${directory}/.`, `${container}:/project`])
await run(docker, ["start", "--attach", container])
await run(docker, [
"cp",
`${container}:/project/electron/dist/.`,
join(root, "electron/dist"),
])
} finally { } finally {
await run(docker, ["rm", "--force", container], {stdio: "ignore"}).catch(() => {})
await rm(directory, {recursive: true, force: true}) await rm(directory, {recursive: true, force: true})
} }
} else { } else {

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root" cd "$root"
[[ -f .fdroid/prepared ]] [[ -f .fdroid/prepared ]]
rm -rf build .svelte-kit rm -rf build .svelte-kit
@ -8,4 +8,4 @@ pnpm exec tsc --module esnext --moduleResolution bundler --target es2020 \
--declaration --skipLibCheck --outDir node_modules/nostr-signer-capacitor-plugin/dist/esm \ --declaration --skipLibCheck --outDir node_modules/nostr-signer-capacitor-plugin/dist/esm \
.fdroid/signer/src/index.ts .fdroid/signer/src/index.ts
export VITE_BUILD_HASH=$(cat .fdroid/build-hash) export VITE_BUILD_HASH=$(cat .fdroid/build-hash)
source scripts/build.sh source scripts/build/app.sh

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.."
if [[ -f .fdroid/prepared ]]; then if [[ -f .fdroid/prepared ]]; then
exit 0 exit 0
fi fi

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