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:
commit
91c17b5e0c
589 changed files with 15829 additions and 15833 deletions
|
|
@ -20,9 +20,8 @@ and UI.
|
|||
| App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other |
|
||||
| Lib | `src/lib` | `@lib/x` | external packages only |
|
||||
|
||||
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`.
|
||||
SvelteKit's built-in `$lib` also resolves, but only four stray imports use it (in `relays.ts`,
|
||||
`callEngine.ts` and `VoiceRoomJoinDialog.svelte`). There is no barrel file, so import each
|
||||
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`, not
|
||||
SvelteKit's built-in `$lib`, which also resolves. There is no barrel file, so import each
|
||||
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`.
|
||||
|
||||
|
|
@ -44,13 +43,16 @@ and `sync.ts`.
|
|||
No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the
|
||||
only gate.
|
||||
|
||||
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`, and `ImageInputButton.svelte`
|
||||
and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app
|
||||
behavior takes it as a prop, or moves to `src/app/components`.
|
||||
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`,
|
||||
`ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal
|
||||
mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor`
|
||||
mounts.
|
||||
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib
|
||||
component that needs app behavior takes it as a prop, or moves to `src/app/components`, as
|
||||
`IconInput` and its picker buttons did.
|
||||
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
|
||||
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`), `speech.ts`
|
||||
(`OpenRouterEnable`) and `healthChecks.ts` (`KeyDownload`, the fix for the key backup check)
|
||||
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`.
|
||||
|
||||
## Top-level layout
|
||||
|
|
@ -71,7 +73,9 @@ only gate.
|
|||
|
||||
## `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.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
|
||||
|
|
@ -81,8 +85,8 @@ only gate.
|
|||
cache
|
||||
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
|
||||
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
|
||||
- `repository.ts`: `derive*` helpers over the current app's repository
|
||||
- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking)
|
||||
- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event
|
||||
- `publications.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`)
|
||||
- `env.ts`: every `VITE_` value, parsed
|
||||
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
|
||||
device id)
|
||||
|
|
@ -95,7 +99,7 @@ only gate.
|
|||
node views
|
||||
|
||||
**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
|
||||
- `access.ts`: joining, invites, relay auth errors
|
||||
- `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
|
||||
|
||||
**Content**
|
||||
- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`) and comment/delete
|
||||
filters
|
||||
- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`), comment/delete
|
||||
filters, and `partitionByActivity`, which the list pages use to sort a kind by latest comment
|
||||
- `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed`
|
||||
- `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
|
||||
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom)
|
||||
- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox)
|
||||
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
|
||||
- `notifications.ts` (unread state, badges, the home inbox)
|
||||
|
||||
**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**
|
||||
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
|
||||
(prompts for missing inbox/outbox relays)
|
||||
|
||||
**Platform and voice**
|
||||
- `push/` (notification adapters), `share.ts`, `keyboard.ts`
|
||||
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter
|
||||
- `push/` (notification adapters, and `server.ts` for the push server's HTTP API), `share.ts`,
|
||||
`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,
|
||||
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
|
||||
client.
|
||||
|
||||
- `util.ts`: small helpers (`errorMessage`, `AbortError`/`TimeoutError`, `buildUrl`,
|
||||
`normalizeTopic`)
|
||||
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`, `compressFile`
|
||||
- `util.ts`: small helpers (`errorMessage`, `buildUrl`, `normalizeTopic`)
|
||||
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`
|
||||
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
|
||||
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
|
||||
`@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)
|
||||
- `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
|
||||
|
||||
`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
|
||||
`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
|
||||
`App` through `login`.
|
||||
2. The device, wallet and notification stores sync to `kv`/`ss`.
|
||||
3. It waits for storage, then handles a cold-start deep link.
|
||||
2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification
|
||||
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`,
|
||||
`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
|
||||
`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,
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
|
|
@ -179,7 +192,7 @@ One web build runs in several shells:
|
|||
Platform checks call Capacitor directly. There is no wrapper module:
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
`registerPlugin` (`push/adapters/android.ts`, `share.ts`).
|
||||
- `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
|
||||
`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
|
||||
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
|
||||
`src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and
|
||||
`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.
|
||||
|
||||
**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:
|
||||
|
||||
```ts
|
||||
|
|
@ -319,15 +333,16 @@ export const getStatus = (event: TrustedEvent) => reader(Classified)(event).stat
|
|||
**Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and
|
||||
`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
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
`makeArticleCreatePath`) instead of in a modal.
|
||||
`makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and
|
||||
renders `ArticleForm`.
|
||||
|
||||
## 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-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
|
||||
- `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-net`: the pool, sockets and socket policies
|
||||
- `welshman-util`: kind constants, tag specs, `RelaySelection`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
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`
|
||||
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
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ domain `Relay`. These fields drive protocol decisions:
|
|||
|
||||
| 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) |
|
||||
| `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`) |
|
||||
|
|
@ -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
|
||||
`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
|
||||
plugin, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a
|
||||
bare `deriveEventsForUrl`.
|
||||
plugin, read `$events.relaySignedForUrl(url, filters)`, which keeps only events signed by that
|
||||
`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
|
||||
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`.
|
||||
- `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.
|
||||
- `deriveUserRooms`, `deriveOtherRooms`, `deriveOtherVoiceRooms`: rooms from the user's 10009
|
||||
list and the rest of the space, limited to rooms the relay still advertises. `meta.hasLivekit()`
|
||||
marks a voice room.
|
||||
- `deriveUserRooms`, `deriveOtherRooms(url, type)`: rooms from the user's 10009 list and the rest
|
||||
of the space by `RoomType`, limited to rooms the relay still advertises. `meta.hasLivekit()`
|
||||
marks a voice room (`getRoomType`).
|
||||
|
||||
### 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
|
||||
`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.
|
||||
`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`,
|
||||
`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
|
||||
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.
|
||||
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.
|
||||
4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`,
|
||||
3. Translate refusals: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code"
|
||||
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.
|
||||
|
||||
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
|
||||
`SpaceInviteAccept`, which calls `Access.acceptInvite` to join the space and then the room.
|
||||
`Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then
|
||||
`createclaim`); this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`,
|
||||
`SpaceInviteAccept`, which calls `acceptInvite` to join the space (skipped if already joined) and
|
||||
then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over
|
||||
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)`.
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `supportedMethods` | `deriveSpaceSupportedMethods` (`src/app/management.ts`), `Access.prepareInvite` |
|
||||
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` |
|
||||
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) |
|
||||
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` |
|
||||
| `listClaims`, `createClaim` | `Access.prepareInvite` |
|
||||
| `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) |
|
||||
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
|
||||
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
|
||||
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
|
||||
| `listClaims`, `createClaim` | `InviteLink` |
|
||||
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
|
||||
|
||||
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
|
||||
five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an
|
||||
error from the call, since a listed method can be refused for a particular event or target.
|
||||
is gated on the method behind it through `deriveSpacePermissions(url)`, a store of named booleans
|
||||
(`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether
|
||||
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
|
||||
on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and
|
||||
`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
|
||||
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,
|
||||
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
|
||||
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
|
||||
relays. `src/app/sync.ts` pulls each space's room state, membership and recent content in the
|
||||
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
|
||||
`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
|
||||
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`.
|
||||
- **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`);
|
||||
relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in
|
||||
`ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62`
|
||||
(`VANISH`).
|
||||
- **Getter exists but bypassed:** titles in `src/app/title.ts`, `NoteContentThread` and the
|
||||
threads pages; calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response`
|
||||
- **Getter exists but bypassed:** 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
|
||||
`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
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
| 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]` |
|
||||
| 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`) |
|
||||
| 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`) |
|
||||
| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member |
|
||||
| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomPinnedMessagesAll` |
|
||||
| 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`, `RoomItemMenuMobile`, `RoomPinnedMessagesAll` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|---|---|---|---|---|
|
||||
| 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 |
|
||||
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` |
|
||||
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` |
|
||||
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenu` |
|
||||
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenu` |
|
||||
|
||||
## 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
|
|
|||
89
.agents/skills/flotilla-release/SKILL.md
Normal file
89
.agents/skills/flotilla-release/SKILL.md
Normal 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.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
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` |
|
||||
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -62,8 +62,8 @@ The following run outside that gate:
|
|||
- every app policy
|
||||
|
||||
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`
|
||||
do.
|
||||
`syncUserSpaceMembership` in `src/app/sync.ts` and `syncCheckedRemote` in
|
||||
`src/app/notifications.ts` do.
|
||||
|
||||
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
|
||||
`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`
|
||||
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
|
||||
listeners re-bind on `app.subscribe`, as `chatsById` in `src/app/chats.ts`,
|
||||
register, and a binding made that way keeps reading the discarded app after login. A
|
||||
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.
|
||||
- **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
|
||||
|
|
@ -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.
|
||||
|
||||
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
|
||||
`@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 |
|
||||
| `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay |
|
||||
| `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:
|
||||
|
||||
|
|
@ -170,20 +177,23 @@ app, and `app.get()` recurses into building another one.
|
|||
## The repository and derived state
|
||||
|
||||
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
|
||||
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing
|
||||
optimistically. The tracker records which relays each event was seen on, which is what lets
|
||||
`repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher`
|
||||
publishing optimistically. The tracker records which relays each event was seen on, which is what lets
|
||||
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`
|
||||
- relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`,
|
||||
`getEventsForUrl`
|
||||
- `deriveLatestEvent`
|
||||
- `one(idOrAddress, relays)` (loads when nothing local matches, keeps deleted events), `byId`,
|
||||
`all`, `asc`, `desc`, `isDeleted`
|
||||
- relay-scoped through the tracker: `forUrl`, `byIdForUrl`, `byIdByUrl`, and
|
||||
`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
|
||||
projections, and flotilla does not use it. Plugin reads (`get`, `one`, `load`, `index`) are
|
||||
documented in welshman-app.
|
||||
Components bind `$events.forUrl(url, filters).$`. A `derive*` function called from gated code
|
||||
binds `events.get().forUrl(...).$` at call time, and a module-scope store wraps it in
|
||||
`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
|
||||
|
||||
|
|
@ -195,14 +205,14 @@ projections and repository derivations:
|
|||
export const deriveSpaceActionItems = (url: string) =>
|
||||
derived(
|
||||
[
|
||||
deriveEventsForUrl(url, [{kinds: [REPORT]}]),
|
||||
events.get().forUrl(url, [{kinds: [REPORT]}]).$,
|
||||
rooms.get().pendingJoins(url).$,
|
||||
deriveSpaceSupportedMethods(url),
|
||||
deriveSpacePermissions(url),
|
||||
],
|
||||
([$reports, $pendingJoins, $methods]) =>
|
||||
([$reports, $pendingJoins, $permissions]) =>
|
||||
sortEventsDesc([
|
||||
...($methods.includes("banevent") ? $reports : []),
|
||||
...($methods.includes("allowpubkey") ? $pendingJoins : []),
|
||||
...($permissions.deleteContent ? $reports : []),
|
||||
...($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:
|
||||
|
||||
- `chatsById` (`chats.ts`) updates incrementally from repository `update` events rather than
|
||||
re-querying.
|
||||
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array
|
||||
wherever an event's thunks are unchanged so rows don't churn.
|
||||
- `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather
|
||||
than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the
|
||||
profiles index.
|
||||
- `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
|
||||
settings behind `throttled(1000, …)`.
|
||||
- `deriveLatestEvent` (`repository.ts`) shares one repository listener across every watched
|
||||
author.
|
||||
- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every
|
||||
watched author.
|
||||
|
||||
## 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 |
|
||||
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
|
||||
| `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 |
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
to `onItem`, and batch the writes.
|
||||
add a `TABLES` entry and an `init*` method that calls `persistMapPlugin` with the table, the
|
||||
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
|
||||
`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`.
|
||||
|
||||
- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the
|
||||
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme`
|
||||
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it.
|
||||
- `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
|
||||
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
|
||||
Each persisted store is declared in its owner module with `synced({key, storage, defaultValue})`,
|
||||
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
|
||||
`.ready` resolves once it has arrived:
|
||||
|
||||
- `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`),
|
||||
`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
|
||||
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
|
||||
`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.
|
||||
|
||||
`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`)
|
||||
|
||||
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.
|
||||
|
||||
- `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.
|
||||
|
||||
`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
|
||||
the room list, so a new filter goes into the right `pullAndListen` call.
|
||||
an `AbortController`. `syncUserData`, `syncSpaces` and `syncDMs` each keep one sync per url with
|
||||
`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
|
||||
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
|
||||
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
|
|
@ -350,8 +371,8 @@ if (room) {
|
|||
eventWriter.setRoom(url, room)
|
||||
}
|
||||
|
||||
const thunk = await command(eventWriter).then(publish)
|
||||
const error = await thunk.waitForError()
|
||||
const publication = await command(eventWriter).then(publish)
|
||||
const error = (await publication.settled()).getError()
|
||||
|
||||
if (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
|
||||
`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
|
||||
(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and
|
||||
`publishRoomQuote` in `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`).
|
||||
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
|
||||
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
|
||||
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
|
||||
`wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`).
|
||||
NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return
|
||||
`{result, error}`, and the caller handles `error`.
|
||||
|
||||
### 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
|
||||
unsigned event for the signed one.
|
||||
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from
|
||||
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts,
|
||||
and a comment carries the same Cancel in the `ThunkPending` row under it.
|
||||
unsigned event for the signed one, and `publication.event` follows it.
|
||||
- **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes
|
||||
the event from the repository and from `history`. When `send_delay` is set, room chat shows a
|
||||
`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
|
||||
`RoomChat.svelte`).
|
||||
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
|
||||
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
|
||||
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
|
||||
whole `history` per row instead.
|
||||
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
|
||||
- **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and
|
||||
pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per
|
||||
relay. A retry drops the publications it replaces from `history`, so the row shows the retry's
|
||||
outcome. `PublicationStatusOrDeleted` combines publish status with deletion.
|
||||
- **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt
|
||||
above.
|
||||
|
||||
## Other app-level stores
|
||||
|
||||
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
|
||||
`allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two
|
||||
stores, so the inbox matches the badges.
|
||||
`allNotifications`, then `notifications` and the counts. The inbox stores
|
||||
(`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`,
|
||||
`currentCallSession`, …).
|
||||
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
|
||||
- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are
|
||||
classes a component instantiates (`new Access(url)`). They hold the writables and actions for
|
||||
a multi-step flow.
|
||||
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the
|
||||
root layout persists.
|
||||
- **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates.
|
||||
It holds the writables and actions for a multi-step flow.
|
||||
- **Module-owned values.** `wallet` in `lightning.ts` persists itself to `ss` with
|
||||
`withGetter(synced(...))`.
|
||||
|
||||
## Runes and stores
|
||||
|
||||
|
|
@ -423,7 +445,7 @@ store, consumed in components with `$store`.
|
|||
Take the first answer that fits:
|
||||
|
||||
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
|
||||
writable.
|
||||
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-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
|
||||
- `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-domain`: the readers and writers behind `reader`, `writer` and `command`
|
||||
- `welshman-net`: the repository, tracker and socket policies under the app
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ const url = decodeRelay(relay)
|
|||
|
||||
That is safe because the layouts remount their children when a param changes.
|
||||
`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}`,
|
||||
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`.
|
||||
|
|
@ -99,7 +100,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves
|
|||
`makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`).
|
||||
- `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`,
|
||||
`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
|
||||
and navigates otherwise; `makeEventPermalink` is the shareable form.
|
||||
- `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`.
|
||||
Without one the tab shows only `PLATFORM_NAME`.
|
||||
|
||||
Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`),
|
||||
the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a
|
||||
plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
|
||||
Deep links arrive in `handleDeepLink` in `src/app/deepLinks.ts`: push-notification links
|
||||
(`?relay=&id=`), the iOS share extension (the `share` host), signer returns (`x-callback-url`), and
|
||||
otherwise a plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
|
||||
loads events before calling `goToEvent`.
|
||||
|
||||
### Adding a space content page
|
||||
|
|
@ -187,15 +190,22 @@ behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
|
|||
### Popover menus
|
||||
|
||||
`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
|
||||
<MenuButton component={ChatMenu} aria-label="Chat options" />
|
||||
```
|
||||
|
||||
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item
|
||||
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes
|
||||
`RoomItemMenuMobile` on tap.
|
||||
`EventMenu` puts `onClick` on its `<ul>` as `onclickcapture`, so any item closes the popover, and
|
||||
each item usually pushes a modal. It has to be the capture phase: `Button` stops propagation, and
|
||||
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
|
||||
|
||||
|
|
@ -204,7 +214,7 @@ usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `Roo
|
|||
```typescript
|
||||
pushToast({message: "Role created!"})
|
||||
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
|
||||
|
|
@ -220,16 +230,19 @@ lives there:
|
|||
- page chrome: `Page`, `PageBar`, `PageContent`, `SecondaryNav*`, `PrimaryNavItem`, `FAB`
|
||||
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
|
||||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
|
||||
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `InputList`, `ToggleInput`,
|
||||
`DateTimeInput`, `ImagesInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||
- display and lists: `Icon`, `ImageIcon`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
|
||||
`Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- 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
|
||||
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
|
||||
its layer exceptions.
|
||||
`component`. `Link` imports `@app` anyway; flotilla-architecture lists it under its layer
|
||||
exceptions.
|
||||
|
||||
`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.
|
||||
|
|
@ -238,8 +251,7 @@ New components go in the flat folder.
|
|||
## Naming
|
||||
|
||||
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`,
|
||||
`ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
|
||||
keeps families adjacent in a flat listing. `ClassifiedCreate`, `ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
|
||||
|
||||
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
|
||||
|
|
@ -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`.
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
`ProfileMultiSelect`, `element` on `PageContent`, `notifications` on `SpaceJoinNotifications`.
|
||||
- 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
|
||||
|
||||
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`,
|
||||
`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">
|
||||
const room = $rooms.forRoom(url, h) // Readable: one, forRoom
|
||||
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
|
||||
</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*`
|
||||
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
|
||||
`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
|
||||
|
|
@ -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
|
||||
lists before loading the author's outbox with `$network.loadLenient`.
|
||||
|
||||
**A detail page:** `deriveEvent(address, [url])` loads when nothing local matches. While it is
|
||||
empty, seven pages show a spinner that turns into a failure message:
|
||||
**A detail page:** `$events.one(address, [url]).$` loads when nothing local matches. While it is
|
||||
empty, seven pages show `LoadingFallback`, a spinner that turns into a failure message after
|
||||
five seconds:
|
||||
|
||||
```svelte
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading listing...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load classified listing.</p>
|
||||
{/await}
|
||||
<LoadingFallback
|
||||
loadingMessage="Loading listing..."
|
||||
failureMessage="Failed to load classified listing." />
|
||||
```
|
||||
|
||||
**Related events:** request them, abort on teardown, and read them back from the repository with
|
||||
`deriveEventsAsc(deriveEventsById(filters))`.
|
||||
`$events.asc(filters).$`.
|
||||
|
||||
```typescript
|
||||
onMount(() => {
|
||||
|
|
@ -373,7 +385,7 @@ onMount(() => {
|
|||
|
||||
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
|
||||
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}>`
|
||||
and an `{#if}` chain over loading, empty and exhausted. Calendars use
|
||||
`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
|
||||
|
||||
flotilla-state covers the writer → command → thunk path. The component around it owns a
|
||||
`loading` flag, awaits the first error, toasts it, and closes:
|
||||
flotilla-state covers the writer → command → publication path. The component around it owns a
|
||||
`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
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const thunk = await command(eventWriter).then(publish)
|
||||
const error = await thunk.waitForError()
|
||||
const publication = await command(eventWriter).then(publish)
|
||||
const error = (await publication.settled()).getError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
history.back()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
pushToast({theme: "error", message: "Failed to save your changes."})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
@ -411,20 +428,20 @@ const submit = async () => {
|
|||
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
|
||||
`.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`.
|
||||
|
||||
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that
|
||||
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are
|
||||
optimistic, so rows show progress in place: the seven per-kind `*Actions` components wrap their
|
||||
contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
|
||||
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
|
||||
which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
|
||||
optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
|
||||
wrap their contents in `PublicationStatusOrDeleted`, and chat pushes a `PublicationToast`.
|
||||
|
||||
## Forms
|
||||
|
||||
`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
|
||||
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:
|
||||
|
||||
|
|
@ -437,31 +454,37 @@ Create/edit pairs come in three shapes:
|
|||
- **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`
|
||||
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`.
|
||||
|
||||
## Styling
|
||||
|
||||
- Classes come from the component families in `src/lib/components/*.css`, which `theme.css`
|
||||
imports: `button` with `button-primary|neutral|link|ghost|error` and
|
||||
`button-sm|xs|circle|square`, `card`, `badge`, `input`, `select`, `textarea`, `menu`. These are
|
||||
flotilla's own; daisyUI is not installed.
|
||||
imports, and the app-concept families in `src/app/components/*.css`, which `src/app/app.css`
|
||||
imports after `theme.css`. A class goes in `@layer components` either way. The lib families
|
||||
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`,
|
||||
`text-content`, `text-content-muted`, `border-line`, `text-primary`, `text-error`. The clay,
|
||||
flat and navy themes supply the values through `data-fl-theme`.
|
||||
- Seven `class:` directives remain as leftovers; everything else builds classes with `cx`.
|
||||
flat, navy and nature themes supply the values through `data-fl-theme`.
|
||||
- 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.
|
||||
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} />`,
|
||||
where `size` counts 4px steps. List entries animate with `in:fly` from `@lib/transition`.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `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
|
||||
- `welshman-app`: plugins, projections, `Command`, thunks, `Feeds`
|
||||
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the other repository stores
|
||||
- `welshman-app`: plugins, projections, `Command`, `Publisher`, `Events`, `Feeds`
|
||||
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the repository stores under `Events`
|
||||
- `welshman-domain`: the readers and writers behind `reader` and `writer`
|
||||
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed`
|
||||
- `welshman-editor`: the composer behind `makeEditor`
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
|
|||
| Suffix | Means | Examples |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
|
||||
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
|
||||
| `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` |
|
||||
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
|
||||
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
|
||||
| `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` |
|
||||
| `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` |
|
||||
| `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
|
||||
`NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`.
|
||||
- `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`).
|
||||
|
||||
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
|
@ -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(...)`.
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
|
|
@ -122,7 +122,7 @@ Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) i
|
|||
| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` |
|
||||
| `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` |
|
||||
| `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)` |
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
// 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)
|
||||
```
|
||||
|
||||
## Publishing (optimistic thunks)
|
||||
## Publishing (optimistic pipeline)
|
||||
|
||||
```typescript
|
||||
import {Thunks, Router} from "@welshman/app"
|
||||
import {Publisher, Router} from "@welshman/app"
|
||||
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
|
||||
|
||||
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
|
||||
// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the
|
||||
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes):
|
||||
const thunk = app.use(Thunks).publish({
|
||||
// Publisher has no outbox helper. Resolve write relays with the Router's Resolver and the
|
||||
// RelaySelection DSL, as Command.publish() does for every data-plugin mutation:
|
||||
const publication = app.use(Publisher).publish({
|
||||
event: makeEvent(NOTE, {content: "hi"}),
|
||||
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:
|
||||
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:
|
||||
thunk.subscribe(t => console.log(t.results))
|
||||
thunk.abort() // effective only before `delay` elapses
|
||||
await thunk.waitForCompletion()
|
||||
thunk.getError() // string | undefined
|
||||
app.use(Thunks).history // writable<Thunk[]> — optimistic log
|
||||
app.use(Thunks).retry(thunk)
|
||||
// A Publication is a Svelte store. `state` is a union keyed by `stage`:
|
||||
// queued → delayed → wrapping → pow → signing → sending → sent | failed | aborted
|
||||
publication.subscribe(p => console.log(p.state.stage, p.results))
|
||||
publication.abort() // unwinds the optimistic write; does nothing once settled
|
||||
publication.canAbort() // true until any relay has been sent to
|
||||
await publication.settled() // resolves on a terminal stage
|
||||
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:
|
||||
app.use(Thunks).publish({event, relays, recipient: theirPubkey})
|
||||
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
|
||||
app.use(Publisher).publish({event, relays, recipient: theirPubkey})
|
||||
const group = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
|
||||
|
||||
// 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)
|
||||
|
||||
|
|
@ -201,11 +206,11 @@ command.app // the IApp it was built for
|
|||
command.event // EventTemplate — unsigned, inspectable before publishing
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -223,7 +228,10 @@ await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl]))
|
|||
import {Network, Sync} from "@welshman/app"
|
||||
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})
|
||||
|
||||
// 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]})
|
||||
|
||||
// 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):
|
||||
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` |
|
||||
| `deriveProfile(pk)` | `app.use(Profiles).one(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) |
|
||||
| `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)])`) |
|
||||
| `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) |
|
||||
| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` |
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ isText isTopic
|
|||
| `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. |
|
||||
| `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. |
|
||||
| `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):
|
||||
|
||||
|
|
@ -123,6 +125,22 @@ const preview = truncate(withGrids, { minLength: 300, maxLength: 500 })
|
|||
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
|
||||
|
||||
```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`.
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,197 +1,350 @@
|
|||
---
|
||||
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
|
||||
accessors over an event) and a **Writer** (a builder that renders a new event), paired in a
|
||||
`KindFactory`.
|
||||
## Overview
|
||||
|
||||
**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the
|
||||
reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader,
|
||||
use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`.
|
||||
`@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:
|
||||
|
||||
- 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
|
||||
|
||||
```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
|
||||
import {KindFactory} from "@welshman/domain"
|
||||
import {Profile, FollowList} from "@welshman/domain"
|
||||
|
||||
KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta`
|
||||
.configure(ctx) // binds a resolver/signer -> ConfiguredKind
|
||||
.reader(event) // -> Reader instance (call .parse() before use)
|
||||
.writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it
|
||||
// Bind dependencies once (the app's Domain plugin does this for you):
|
||||
const profiles = Profile.configure(context) // context: KindContext
|
||||
const follows = FollowList.configure(context) // context.signer decrypts private tags
|
||||
|
||||
// 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
|
||||
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
|
||||
const note = app.use(Domain).reader(Note)(event)
|
||||
// read side (event decoder for a data plugin):
|
||||
eventToItem: app.use(Domain).reader(Note) // ConfiguredKind.reader
|
||||
|
||||
note.content() // typed accessors, no tag digging
|
||||
note.author()
|
||||
note.createdAt()
|
||||
// fetch side — query → filters + relays:
|
||||
const {filters, relays} = await app.use(Domain).query(Note).setAuthors([pubkey]).render()
|
||||
|
||||
// Write: build, then wrap in a Command to publish
|
||||
const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey)
|
||||
const command = await app.use(Domain).command(writer)
|
||||
|
||||
await command.publish().waitForError()
|
||||
|
||||
// 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")
|
||||
// mutation side — writer → Command → publish:
|
||||
const reader = existingReader // from a prior read, or undefined
|
||||
const writer = app.use(Domain).writer(FollowList, reader).follow(pubkey)
|
||||
const command = await app.use(Domain).command(writer) // render() + wrap
|
||||
command.publish() // or .publishToRelays(urls)
|
||||
```
|
||||
|
||||
## 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`
|
||||
- `AsyncEventReader.parse(): Promise<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)`.
|
||||
- **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)`
|
||||
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.
|
||||
## OLD → NEW migration
|
||||
|
||||
```typescript
|
||||
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 |
|
||||
| Old API | New API |
|
||||
|---|---|
|
||||
| `id()` | event id |
|
||||
| `author()` | pubkey |
|
||||
| `content()` | content string |
|
||||
| `tags()` | raw tags (prefer a typed getter when one exists) |
|
||||
| `createdAt()` | timestamp |
|
||||
| `identifier()` | `d` tag |
|
||||
| `address()` | `kind:pubkey:d` |
|
||||
| `room()` | `h` tag |
|
||||
| `protect()` | whether the event carries `-` (NIP-70) |
|
||||
| `expiration()` | `expiration` tag as a number |
|
||||
| `emojis()` | parsed `emoji` tags |
|
||||
| `zapSplits()` | parsed `zap` tags |
|
||||
|
||||
Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`,
|
||||
`RelayMembersReader.isMember(pubkey)`, and so on.
|
||||
|
||||
## 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.
|
||||
| `new Kind({reader, builder, router})` | `new KindFactory({kind, reader, writer, query})` |
|
||||
| `Kind` class | `KindFactory` (+ `ConfiguredKind` after `.configure`) |
|
||||
| `EventBuilder` (base) / `XBuilder` | `EventWriter` / `XWriter` |
|
||||
| `ListBuilder` | `ListWriter` |
|
||||
| `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) |
|
||||
| `Kind.builder(...)` / `new XBuilder(...)` | `factory.configure(ctx).writer(reader?)` |
|
||||
| `builder.toTemplate()` | `writer.renderTemplate()` → `Promise<EventTemplate>` |
|
||||
| `builder.toEvent(signer)` | `signer.sign(stamp(await writer.renderTemplate()))` |
|
||||
| `builder.toRumor(signer)` | `prep(await writer.renderTemplate(), await signer.getPubkey())` |
|
||||
| `Router.commandFromBuilder(builder)` | `app.use(Domain).command(writer)` |
|
||||
| `parse(signer)` | `parse()` (reads `def.context.signer`) |
|
||||
| `builder.finalize(context)` | `writer.render()` (no arg; context bound at `configure`) |
|
||||
| standalone `resolve(...)` | `new Resolver(routeResolver, options)` → `.scenario`/`.relays`/`.relay` |
|
||||
| `relayHint(url)` / `relayHints(urls)` | `relay(url)` / `relays(urls)` |
|
||||
| — (new) | `inboxes(pubkeys, weight?)`, `Resolver`, `RelayScenario`, `KindContext` |
|
||||
|
||||
## Related skills
|
||||
|
||||
- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you
|
||||
- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters
|
||||
- `welshman-store` — indexing decoded readers into reactive collections
|
||||
- `welshman-util` — the raw `TrustedEvent`/`EventTemplate` types, kind constants, tag getters, and the `RelaySelection` routing DSL + `Resolver`/`RelayScenario` these classes build on.
|
||||
- `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-signer` — the `ISigner` interface and NIP-44 `decrypt`/`encrypt` used for private list tags and for signing rendered templates.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -54,12 +54,13 @@ Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader
|
|||
| `requestOne(options)` | Subscribe to a single relay; 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 |
|
||||
| `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`):
|
||||
- `relay` / `relays` — relay url(s)
|
||||
- `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
|
||||
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
|
||||
- `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.
|
||||
|
||||
### 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
|
||||
|
||||
| Export | Description |
|
||||
|
|
@ -169,8 +181,8 @@ Emits `"update"` with a `RepositoryUpdate` on every change.
|
|||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types |
|
||||
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages |
|
||||
| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages |
|
||||
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, `isRelayCount()`, … | Type guards for relay 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:`, …) |
|
||||
|
||||
### WrapManager
|
||||
|
|
@ -221,8 +233,10 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
|
|||
import {makeLoader} from '@welshman/net'
|
||||
|
||||
// Bind a loader to the context once; concurrent calls within `delay` collapse
|
||||
// into a single REQ per relay.
|
||||
const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context})
|
||||
// into a single REQ per relay. Without a `threshold` it resolves when every relay has closed
|
||||
// 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({
|
||||
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- **`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.
|
||||
- **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.
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ interface StorageProvider {
|
|||
| Export | Description |
|
||||
|---|---|
|
||||
| `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
|
||||
|
||||
|
|
@ -320,7 +320,7 @@ const loadBookmark = makeLoadItem<Bookmark>(
|
|||
async (pubkey: string) => {
|
||||
const scenario = await app.use(Router).resolve([outbox(pubkey)])
|
||||
|
||||
await app.use(Network).load({
|
||||
await app.use(Network).loadComplete({
|
||||
relays: scenario.getUrls(),
|
||||
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -75,8 +75,10 @@ NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welsh
|
|||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` |
|
||||
| `getPow(event)` | Leading zero bits on an event's id |
|
||||
| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits, in a web worker; returns a `ProofOfWork` |
|
||||
| `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 |
|
||||
|
||||
### Event Kinds (constants)
|
||||
|
|
@ -542,7 +544,7 @@ sendManagementRequest(url: string, request: ManagementRequest, authEvent: Signed
|
|||
// 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.
|
||||
|
||||
|
|
|
|||
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1 +1,3 @@
|
|||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
pnpm-lock.yaml -diff
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ on:
|
|||
push:
|
||||
branches: [master]
|
||||
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:
|
||||
|
||||
# Serialized rather than cancel-in-progress: aborting a push halfway leaves the
|
||||
|
|
@ -14,7 +18,7 @@ concurrency:
|
|||
jobs:
|
||||
push-to-github:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'coracle/flotilla'
|
||||
if: github.repository == 'coracle/flotilla' && github.event_name != 'schedule'
|
||||
|
||||
steps:
|
||||
- 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" \
|
||||
'refs/heads/master:refs/heads/master' \
|
||||
'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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
name: Container Image Build and Publish
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["*.*.*"]
|
||||
# Reruns steps of the release for package.json's version, from the branch it's run on
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
steps:
|
||||
description: Steps to run, such as "fdroid" (all when empty)
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
|
@ -14,30 +20,9 @@ env:
|
|||
IMAGE_NAME: coracle/flotilla
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install dependencies
|
||||
run: corepack enable && pnpm i --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Check
|
||||
run: pnpm run check
|
||||
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-check
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
|
@ -59,8 +44,8 @@ jobs:
|
|||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -68,7 +53,6 @@ jobs:
|
|||
driver: docker-container
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
|
|
@ -77,3 +61,31 @@ jobs:
|
|||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
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
|
||||
|
|
@ -2,3 +2,6 @@
|
|||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Formatted by fdroidserver's rewritemeta, which fdroiddata checks
|
||||
fdroid/metadata/
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -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
|
||||
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.
|
||||
- **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.
|
||||
|
|
@ -82,7 +82,7 @@ Conventions below.
|
|||
A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in
|
||||
callbacks and hot paths.
|
||||
|
||||
**Thunks:**
|
||||
**Publications** (`app.use(Publisher).publish`):
|
||||
|
||||
- Reduce UI latency by handling signatures and sending in background
|
||||
- Return status that should be displayed to user
|
||||
|
|
@ -93,7 +93,7 @@ callbacks and hot paths.
|
|||
|
||||
**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/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
|
||||
- `@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
|
||||
2. Wrap it: `const command = await app.use(Domain).command(writer)`
|
||||
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
|
||||
return a `Command`, so `.then(publish)` is usually all you need.
|
||||
|
|
@ -235,7 +235,7 @@ See `.env.template` for all options.
|
|||
|
||||
**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)
|
||||
- PWA: Progressive Web App with service worker
|
||||
|
||||
|
|
|
|||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -1,5 +1,40 @@
|
|||
# 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
|
||||
|
||||
* Replace the home page with a dashboard of unread conversations, space activity and a network feed
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 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`
|
||||
- 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.
|
||||
- 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`.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
# docker run -p 3000:3000 flotilla
|
||||
#
|
||||
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
||||
# A .env in the build context is picked up by scripts/build.sh for branding config.
|
||||
# A .env in the build context is picked up by scripts/build/app.sh for branding config.
|
||||
#
|
||||
# Self-hosted deployments can override branding/feature settings by passing
|
||||
# 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.
|
||||
|
||||
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||
|
|
@ -20,6 +20,7 @@ RUN corepack enable
|
|||
WORKDIR /app
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages ./packages
|
||||
RUN pnpm i --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
|
|
@ -41,7 +42,8 @@ FROM node:24-slim AS production
|
|||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
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
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
90
README.md
90
README.md
|
|
@ -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_PRIVACY` - URL to your privacy policy page
|
||||
- `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**
|
||||
- `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_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.
|
||||
|
||||
|
|
@ -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
|
||||
uses the executable's icon resources.
|
||||
|
||||
Linux packaging requires Linux. Windows packaging from Linux uses the pinned official
|
||||
`electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared
|
||||
Electron project. Native addons need a target-OS ABI rebuild and cannot use this cross-build path.
|
||||
Linux packaging from macOS and Windows packaging from Linux or macOS use the pinned official
|
||||
`electronuserland/builder` Wine image through Docker, copying in only a temporary copy of the prepared
|
||||
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
|
||||
Linux, `pnpm run package:desktop:macos --dir` prepares unsigned bundles for inspection only, and
|
||||
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
|
||||
|
||||
`pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects,
|
||||
the signed APK on gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect,
|
||||
and the desktop packages. It checks the tag, the changelog section, every credential and every
|
||||
tool up front, and refuses to start if any is missing. It finishes with a list of what's left to do
|
||||
by hand, such as rolling out on Play and submitting for review.
|
||||
A release is two runs against one tag. `pnpm release:local` does everything that needs a signing
|
||||
key, so those keys never leave your machine: the web bundle and native projects, the signed APK on
|
||||
gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect, and the signed and
|
||||
notarized macOS packages. Pushing the tag starts the release workflow in
|
||||
`.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
|
||||
pnpm bump minor # or patch, major, or an explicit x.y.z
|
||||
# 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
|
||||
pnpm release
|
||||
pnpm release:local
|
||||
```
|
||||
|
||||
`pnpm release --check` runs those checks and reports the plan without building anything. Naming
|
||||
steps runs a subset, such as `pnpm release ios` or `pnpm release apk gitea`. A step that fails stops the
|
||||
run and prints the command to pick up from there.
|
||||
`pnpm release:local --check` runs those checks and reports the plan without building anything.
|
||||
Naming steps runs a subset, such as `pnpm release:local ios` or `pnpm release:local apk gitea`. A
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `web` | `scripts/build.sh`: web bundle, `cap sync`, generated icons and splash screens |
|
||||
| `apk` | `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` | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft |
|
||||
| `ios` | `cap build ios` to an archive and IPA, uploaded with `altool` |
|
||||
| `desktop` | `package:desktop:*` for this OS: Linux and Windows from Linux, signed and notarized macOS from a Mac |
|
||||
| `gitea` | creates a draft release from the changelog, attaches the APK, desktop packages and update manifests, and publishes it once every platform is there |
|
||||
| `zapstore` | `zsp publish zapstore.yaml` |
|
||||
| step | run by | what it does |
|
||||
| --- | --- | --- |
|
||||
| `web` | local | `scripts/build/app.sh`: web bundle, `cap sync`, generated icons and splash screens |
|
||||
| `apk` | local | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` |
|
||||
| `play` | local | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft (or `PLAY_STATUS`) |
|
||||
| `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 |
|
||||
| `fdroid` | ci | builds the tag with F-Droid's recipe in their buildserver image and uploads the unsigned apk to the `flotilla-fdroid` package |
|
||||
| `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 |
|
||||
| `desktop` | both | `package:desktop:*` for this OS: signed and notarized macOS from a Mac, Linux and Windows from Linux |
|
||||
| `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
|
||||
run on each, both ending in `gitea`. Gitea's latest release is the desktop update feed, so the release stays a
|
||||
draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml`
|
||||
manifests. Each manifest is uploaded after the files it lists. A mobile-only release can't be
|
||||
published, so package the desktop apps for every release.
|
||||
Gitea's latest release is the desktop update feed, so the release stays a draft, hidden from
|
||||
updaters and Obtainium, until it has the APK and all three `latest*.yml` manifests. Whichever run
|
||||
attaches the last of them publishes it. Each manifest is uploaded after the files it lists. A
|
||||
mobile-only release can't be published, so package the desktop apps for every release.
|
||||
|
||||
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
|
||||
`zapstore.yaml`.
|
||||
|
||||
F-Droid builds from the tag on its own servers, so the `fdroid` step uploads nothing. It runs
|
||||
[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree and
|
||||
fails the release before anything is published if that build breaks. Preparation patches source
|
||||
with exact-match replacements, so it breaks quietly when the files it rewrites change. The step is
|
||||
slow because it installs and builds from scratch.
|
||||
F-Droid builds from the tag on its own servers and ships our apk instead of its own when the two
|
||||
match, so it keeps our signature ([reproducible builds](fdroid/README.md)). The `fdroid` step makes
|
||||
that apk the way F-Droid will. It runs last in CI, so a broken F-Droid build holds back only
|
||||
F-Droid and `fdroid-sign`, not the desktop packages or the release. Preparation patches source with
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
|
|
@ -274,7 +286,9 @@ Or, if you prefer to use a container:
|
|||
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
|
||||
mkdir ./mount
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 52
|
||||
versionName "1.11.0"
|
||||
versionCode 55
|
||||
versionName "1.11.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -39,9 +39,7 @@
|
|||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
|
|
|||
|
|
@ -200,12 +200,10 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val body = "New activity"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle("Flotilla")
|
||||
.setContentText(body)
|
||||
.setContentText("New message")
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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
188
android/gradlew.bat
vendored
|
|
@ -1,94 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 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
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@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" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 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
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@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" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
|
|||
<tr>
|
||||
<td>Push notifications</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>
|
||||
<td>Threads</td>
|
||||
|
|
@ -461,7 +461,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
|
|||
<tr>
|
||||
<td>Branded Android app</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>
|
||||
<td>Branded iOS app</td>
|
||||
|
|
@ -471,7 +471,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
|
|||
<tr>
|
||||
<td>Branded desktop app</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>
|
||||
<td>PWA / installable web app</td>
|
||||
|
|
|
|||
|
|
@ -442,6 +442,16 @@ Acceptance:
|
|||
message on screen and no "jump to newest" control, even when other events were
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
- Typing a term filters the shelf list to shelves whose title, description, or
|
||||
topics match.
|
||||
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
|
||||
instead.
|
||||
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone
|
||||
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf".
|
||||
- A shelf card carries its title, its description, how many links are on it and
|
||||
who has contributed.
|
||||
- Typing a term filters the shelves to those whose title, description or topics
|
||||
match, and lists the links that match from any shelf.
|
||||
- Opening a shelf shows its links; one that already holds a link still offers
|
||||
"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
|
||||
|
||||
|
|
@ -889,13 +902,56 @@ good material first.
|
|||
|
||||
Acceptance:
|
||||
|
||||
- Creating a shelf with a title and description adds it to the list and
|
||||
navigates into it; editing and deleting it from its menu update the list.
|
||||
- Adding a pin with an external url shows it as a content card, and adding one
|
||||
with a nostr note link renders that note as a rich embed.
|
||||
- Creating a shelf with a title, description and topics opens it; editing it
|
||||
from its menu updates its page, and deleting it returns to the library.
|
||||
- Several links pasted at once each become a link of its own and are offered no
|
||||
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
|
||||
as a pin visible in that shelf; with no shelves yet, the dialog prompts to
|
||||
create one.
|
||||
as a link on that shelf; with no shelves yet, the dialog prompts to create
|
||||
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
|
||||
|
||||
|
|
@ -1074,6 +1130,19 @@ Acceptance:
|
|||
- Clicking that card opens the quoted post.
|
||||
- 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
|
||||
|
||||
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!"
|
||||
once the relay confirms; a failed retry leaves no success message and can be
|
||||
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!"
|
||||
indicator and restores the normal reaction and menu actions, with no toast.
|
||||
|
||||
|
|
@ -1165,6 +1236,8 @@ Acceptance:
|
|||
thread it was posted into.
|
||||
- A failed post shows "Failed to send!" in the same row, opening the same
|
||||
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
|
||||
|
||||
|
|
@ -1279,6 +1352,18 @@ Acceptance:
|
|||
- A note alice publishes from her own session appears at the top of bob's open
|
||||
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
|
||||
|
||||
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
|
||||
off, leaving badge and sound settings untouched.
|
||||
- "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
|
||||
|
||||
|
|
@ -1404,8 +1493,8 @@ Acceptance:
|
|||
- Selecting "Dark" applies the dark theme to the document immediately, and
|
||||
"System" follows the OS color-scheme preference.
|
||||
- Choosing a different app theme applies it and persists after a reload.
|
||||
- Dragging the font-size slider updates the displayed percentage and saves
|
||||
without a separate save button.
|
||||
- Dragging the font-size slider updates the displayed percentage, and saving the
|
||||
page applies it and persists it across a reload.
|
||||
|
||||
### 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.
|
||||
- Deleting a role warns that members keep their space membership, and confirming
|
||||
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
|
||||
|
||||
|
|
@ -1461,8 +1552,8 @@ Acceptance:
|
|||
|
||||
- "Create Invite" shows a QR code and a copyable link, and copying puts it on
|
||||
the clipboard.
|
||||
- Where the relay supports it, the invite dialog's member search adds someone
|
||||
straight into the member list.
|
||||
- Where the relay supports it, "Add members" in the invite dialog opens a member
|
||||
search that adds someone straight into the member list.
|
||||
- A room-scoped invite link carries both the space and that room's join code.
|
||||
|
||||
### US-095 — Remove, ban, and restore members
|
||||
|
|
@ -1538,6 +1629,24 @@ Acceptance:
|
|||
- A member given "List banned members" finds "Banned Members" in the directory
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
**External link-outs.** Self-hosted and third-party options on the
|
||||
space-creation page, the About page's source, blog, podcast and support links,
|
||||
and a note's timestamp permalink to another nostr client. The harness blocks
|
||||
navigation off the app's origin, so nothing about the destination is observable.
|
||||
space-creation page, and the About page's source, blog, podcast and support
|
||||
links. The harness blocks navigation off the app's origin, so nothing about the
|
||||
destination is observable.
|
||||
|
||||
**Diagnostic log sending.** The privacy page's button that bundles client-side
|
||||
logs into a DM to the platform's support contact. It targets a hardcoded pubkey
|
||||
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
**Internals with no user-visible surface.** The legacy session-storage format
|
||||
|
|
|
|||
|
|
@ -2,35 +2,45 @@ import type {BrowserContext} from "@playwright/test"
|
|||
import {ms} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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.
|
||||
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
|
||||
// has that the hook in src/app/env.ts ran.
|
||||
// Set the first time the app reads the injected env, the only evidence here that src/app/env.ts ran.
|
||||
const TEST_ENV_READ_KEY = "__TEST_ENV_READ__"
|
||||
|
||||
export type BootOptions = {
|
||||
// Every relay list the app reads at startup is pointed here, so it can only dial relays the
|
||||
// scenario created.
|
||||
// Every relay list the app reads at startup is pointed here, so it can only dial the scenario's.
|
||||
relays: string[]
|
||||
// What a pubkey's own lists are resolved from, which is a relay of its own only when the
|
||||
// scenario opened one. Defaults to `relays`.
|
||||
// What a pubkey's own lists are resolved from, defaulting to `relays`.
|
||||
indexers?: string[]
|
||||
spaces?: string[]
|
||||
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.
|
||||
events?: TrustedEvent[]
|
||||
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
|
||||
checked?: Record<string, number>
|
||||
path?: string
|
||||
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything
|
||||
// named here has to be something the test owns, or the test fails on a leak.
|
||||
// VITE_ values the scenario sets for itself. Anything named here has to be something the test owns.
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export const boot = async (
|
||||
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(",")
|
||||
|
||||
|
|
@ -58,8 +68,7 @@ export const boot = async (
|
|||
VITE_DEFAULT_SPACES: spaces.join(","),
|
||||
VITE_PLATFORM_RELAYS: "",
|
||||
VITE_BLOCKED_RELAYS: "",
|
||||
// Nothing serves this url, so a push bridge connection is reported as a leak instead of
|
||||
// blending into the traffic of a relay the scenario did create.
|
||||
// Nothing serves this url, so a push bridge connection is reported as a leak.
|
||||
VITE_PUSH_BRIDGE: "ws://localhost:1/",
|
||||
...env,
|
||||
},
|
||||
|
|
@ -67,18 +76,19 @@ export const boot = async (
|
|||
)
|
||||
|
||||
if (user) {
|
||||
await injectSession(context, user)
|
||||
await injectSession(context, user, nip07)
|
||||
await injectEvents(context, events)
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
await injectChecked(context, checked)
|
||||
}
|
||||
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.goto(path)
|
||||
|
||||
// The root layout renders nothing until its async setup block resolves, so the shell appearing
|
||||
// is the first point at which the app is 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.
|
||||
// The root layout renders nothing until its async setup resolves, and a rejected session renders the landing dialog.
|
||||
const shell = page.locator(user ? ".primary-nav" : ".fl")
|
||||
|
||||
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
|
||||
// against either the env above or .env's real relays.
|
||||
// src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them.
|
||||
const usedTestEnv = await page.evaluate(
|
||||
key => Boolean(Reflect.get(window, key)),
|
||||
TEST_ENV_READ_KEY,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import type {Page} from "@playwright/test"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
|
||||
// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to
|
||||
// one identity.
|
||||
// Must match the database name and the `events` table in src/app/storage.ts.
|
||||
const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}`
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** What this user's client has written to disk. Events reach indexeddb in three-second batches. */
|
||||
export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> =>
|
||||
page.evaluate(async name => {
|
||||
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)
|
||||
})
|
||||
|
||||
// An unversioned open creates the database when it is missing, so a client that has not written
|
||||
// anything yet has no store to read.
|
||||
// An unversioned open creates the database when it is missing, so a client with no writes has no store.
|
||||
if (!open.objectStoreNames.contains("events")) {
|
||||
open.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import type {BrowserContext} from "@playwright/test"
|
|||
import type {StampedEvent} from "@welshman/util"
|
||||
import type {TestUser} from "../keys"
|
||||
|
||||
// The function playwright installs on window for the shim below to call into. The keys live in
|
||||
// node, so a signer built in the page would not be the one seeding signs with.
|
||||
// The keys live in node, so a signer built in the page would not be the one seeding signs with.
|
||||
const TEST_NIP07_KEY = "__TEST_NIP07__"
|
||||
|
||||
type Nip07Call =
|
||||
|
|
@ -11,13 +10,7 @@ type Nip07Call =
|
|||
| {method: "signEvent"; template: StampedEvent}
|
||||
| {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string}
|
||||
|
||||
/**
|
||||
* A NIP-07 provider backed by a test identity's real signer, so an extension login produces
|
||||
* signatures the relays accept, including the NIP-42 auth events a members-only relay demands.
|
||||
*
|
||||
* Both halves have to be installed before the page navigates. LogIn.svelte reads `window.nostr`
|
||||
* while it renders, to decide whether to offer the button at all.
|
||||
*/
|
||||
/** A NIP-07 provider backed by a test identity's real signer, installed before the page navigates. */
|
||||
export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
|
||||
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {
|
||||
if (call.method === "getPublicKey") {
|
||||
|
|
@ -36,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
|
|||
})
|
||||
|
||||
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, {
|
||||
nostr: {
|
||||
|
|
|
|||
|
|
@ -7,19 +7,20 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
|
|||
|
||||
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
|
||||
|
||||
// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so
|
||||
// restoreSession can build a signer from it without the storage encoding a real login goes through.
|
||||
// addInitScript runs before any page script, so this has to be called before navigating.
|
||||
export const injectSession = (context: BrowserContext, user: TestUser) =>
|
||||
// The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
|
||||
// A nip07 session carries no key and signs through whatever injectNip07 put on window.
|
||||
export const injectSession = (context: BrowserContext, user: TestUser, nip07 = false) =>
|
||||
context.addInitScript(
|
||||
([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
|
||||
// returning user would boot with.
|
||||
// The local cache a returning user would boot with, loaded once the injected session is restored.
|
||||
export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
|
||||
context.addInitScript(
|
||||
([key, value]) => {
|
||||
|
|
@ -27,3 +28,15 @@ export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
|
|||
},
|
||||
[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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
import type {BrowserContext} from "@playwright/test"
|
||||
|
||||
// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the
|
||||
// connection on, and the node's alias is what the wallet page names the connection by.
|
||||
// `supports` is what WalletConnect.svelte gates the connection on, and the alias names it on screen.
|
||||
export type WebLnInfo = {
|
||||
supports?: string[]
|
||||
node?: {alias?: string; pubkey?: string}
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A WebLN provider on `window`, installed before the page navigates: WalletConnect reads it while it renders. */
|
||||
export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) =>
|
||||
context.addInitScript(
|
||||
$info => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import {inspect} from "node:util"
|
||||
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
|
||||
// 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).
|
||||
// A CSP refusal reaches the console and nothing else, so a policy that has rotted is invisible (#535).
|
||||
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
|
||||
// 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.
|
||||
// Recreating the container between tests aborts route chunks in flight, until #529 stops the churn.
|
||||
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
|
||||
// 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.
|
||||
// Chrome carries a failed request's url nowhere but the message's location.
|
||||
const locate = (message: ConsoleMessage) => {
|
||||
const text = message.text()
|
||||
const {url} = message.location()
|
||||
|
|
@ -22,9 +15,7 @@ const locate = (message: ConsoleMessage) => {
|
|||
return url && !text.includes(url) ? `${text} ${url}` : text
|
||||
}
|
||||
|
||||
// Playwright builds this from the page's exception details, and a page that throws something other
|
||||
// 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:".
|
||||
// A page that throws something other than an Error leaves playwright neither message nor stack.
|
||||
const describe = (error: Error) => error.stack || error.message || inspect(error)
|
||||
|
||||
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.
|
||||
found: string[]
|
||||
observe(context: BrowserContext, who: string): void
|
||||
// Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the
|
||||
// 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.
|
||||
// Throws on an uncaught exception or code the browser refused to run, not on a failed request.
|
||||
assertNone(): void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ export type TestFile = {
|
|||
buffer: Buffer
|
||||
}
|
||||
|
||||
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||||
// 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().
|
||||
// Gif rather than png, since compressFileForUpload passes it through instead of re-encoding it.
|
||||
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
|
||||
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})
|
||||
|
||||
// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand
|
||||
// it a file: the input behind it is never on screen.
|
||||
// Every picker opens the browser's own chooser, and the input behind it is never on screen.
|
||||
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
formatTranscript,
|
||||
installWebSocketRoutes,
|
||||
silenceRelay,
|
||||
withholdEose,
|
||||
} from "./net/websocket"
|
||||
import {watchFaults} from "./faults"
|
||||
import {boot} from "./app/boot"
|
||||
|
|
@ -47,6 +48,7 @@ export {
|
|||
getPublishedEvents,
|
||||
getTranscript,
|
||||
silenceRelay,
|
||||
withholdEose,
|
||||
} from "./net/websocket"
|
||||
export {readCachedEvents} from "./app/cache"
|
||||
export {
|
||||
|
|
@ -86,12 +88,14 @@ export {
|
|||
getHosting,
|
||||
mockBlossom,
|
||||
mockDufflepud,
|
||||
mockGeocoding,
|
||||
mockLivekit,
|
||||
mockOpenRouterSpeech,
|
||||
mockOpenRouterTranscription,
|
||||
} from "./net/http"
|
||||
export type {
|
||||
DufflepudFixtures,
|
||||
GeocodedPlace,
|
||||
HostingFixtures,
|
||||
HostingHandle,
|
||||
HostingRecord,
|
||||
|
|
@ -99,8 +103,7 @@ export type {
|
|||
} from "./net/http"
|
||||
export type {WebLnInfo} from "./app/webln"
|
||||
|
||||
// Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with
|
||||
// it sveltekit.
|
||||
// Mirrors encodeRelay in src/app/relays.ts, which cannot be imported without pulling in sveltekit.
|
||||
const encodeRelay = (url: string) =>
|
||||
encodeURIComponent(
|
||||
normalizeRelayUrl(url)
|
||||
|
|
@ -112,51 +115,44 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
|
|||
|
||||
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
|
||||
// with commas — see makeChatId in src/app/chats.ts.
|
||||
// Mirrors makeChatId in src/app/chats.ts.
|
||||
export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
|
||||
|
||||
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
|
||||
// path being matched, or a host whose dots would otherwise be wildcards.
|
||||
// Matches a path literally, for one whose query string, hash or dots would otherwise be wildcards.
|
||||
export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
|
||||
// What a page is opened with, over and above the scenario's own relays.
|
||||
export type PageOptions = {
|
||||
// Overrides the project's context options, for a spec that needs a viewport or a permission of
|
||||
// its own.
|
||||
// Overrides the project's context options, for a spec needing its own viewport or permission.
|
||||
context?: BrowserContextOptions
|
||||
// VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space or
|
||||
// the domain hosted relays are created under. See BootOptions in app/boot.ts.
|
||||
// VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
|
||||
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
|
||||
// A WebLN provider on window, for a wallet that gets connected through an extension.
|
||||
webln?: WebLnInfo
|
||||
// A blossom server, installed before the page boots. mockBlossom called on the page `as()`
|
||||
// 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.
|
||||
// A blossom server installed before boot, for a url src/app/sync.ts probes and caches on load.
|
||||
blossom?: BlossomOptions
|
||||
// Fields merged over a relay's own nip-11 document, keyed by relay url.
|
||||
relayInfo?: RelayInfoOverrides
|
||||
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for
|
||||
// the handle that changes it mid-test.
|
||||
// What the hosting backend already knows about this user; getHosting changes it mid-test.
|
||||
hosting?: HostingFixtures
|
||||
// Relay urls that take the socket and answer nothing, from the page's first connection onward.
|
||||
// `silenceRelay(page.context(), url)` is the same fault applied mid-test, which takes effect on
|
||||
// the next connection rather than this one.
|
||||
// Which paths this user has already read, for a spec that needs unread messages at first paint.
|
||||
checked?: Record<string, number>
|
||||
// Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test.
|
||||
silent?: string[]
|
||||
// Relay urls that serve events and never eose, so every request runs out its own deadline.
|
||||
eoseless?: string[]
|
||||
}
|
||||
|
||||
export type Harness = {
|
||||
zooid: Zooid
|
||||
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
|
||||
// sockets into the relays every other user is talking to.
|
||||
// A logged-in page for a user, in its own browser context.
|
||||
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
|
||||
// happen.
|
||||
// The same page with no session injected, which is the only way to watch a login or a logout.
|
||||
visit(path?: string, options?: PageOptions): Promise<Page>
|
||||
}
|
||||
|
||||
|
|
@ -172,8 +168,7 @@ export type 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
|
||||
// there boots the app against the relays baked into .env.
|
||||
// Playwright's own context is unrouted, so a page born there boots against the relays in .env.
|
||||
context: async () => {
|
||||
throw new Error(
|
||||
"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.",
|
||||
)
|
||||
},
|
||||
// Playwright builds this one with `playwright.request.newContext()`, an http client in this
|
||||
// process that belongs to no browser context, so `installHttpRoutes` cannot see it.
|
||||
// Playwright builds this with request.newContext(), which belongs to no browser context.
|
||||
request: async () => {
|
||||
throw new Error(
|
||||
"The built-in `request` fixture makes http requests from node, where nothing intercepts " +
|
||||
"them. Anything the app fetches belongs in a mock installed by `as(user, path)`.",
|
||||
)
|
||||
},
|
||||
// One container per worker, torn down when the worker ends. It is recreated between tests, in
|
||||
// the teardown of the test that finishes rather than the setup of the one that starts.
|
||||
// One container per worker, recreated in the teardown of the test that finishes.
|
||||
zooid: [
|
||||
// playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern
|
||||
// even when there are none.
|
||||
// playwright reads a fixture's dependencies off this pattern, so it stays a pattern.
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
async ({}, use) => {
|
||||
const zooid = new Zooid()
|
||||
|
|
@ -228,12 +220,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
const {urls, indexerUrls, cache} = requireScenario()
|
||||
|
||||
await zooid.settle()
|
||||
// The project's own `use` first, so a viewport or device descriptor set in
|
||||
// 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.
|
||||
// context.route does not see a request a service worker makes, and sveltekit registers one in dev.
|
||||
const context = await browser.newContext({
|
||||
...testInfo.project.use,
|
||||
serviceWorkers: "block",
|
||||
|
|
@ -242,14 +229,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
|
||||
contexts.push({name: user?.name ?? "anonymous", context})
|
||||
|
||||
// `use.trace` and the built-in reporting only cover contexts playwright made itself, so a
|
||||
// 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.
|
||||
// use.trace and the built-in reporting only cover contexts playwright made itself.
|
||||
faults.observe(context, user?.name ?? "anonymous")
|
||||
|
||||
// Playwright matches the most recently registered route first and every mock falls through
|
||||
// what it doesn't recognize, so the block-all goes in before the mocks, and all of it before
|
||||
// the page navigates.
|
||||
// Playwright matches the most recently registered route first, so the block-all goes in first.
|
||||
await installHttpRoutes(context)
|
||||
await installWebSocketRoutes(context, zooid)
|
||||
|
||||
|
|
@ -257,6 +240,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
silenceRelay(context, url)
|
||||
}
|
||||
|
||||
for (const url of options.eoseless ?? []) {
|
||||
withholdEose(context, url)
|
||||
}
|
||||
|
||||
await mockRelayInfo(context, options.relayInfo ?? {})
|
||||
await mockAnalytics(context)
|
||||
await mockDufflepud(context)
|
||||
|
|
@ -276,9 +263,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
await injectWebLn(context, options.webln)
|
||||
}
|
||||
|
||||
// Headless Chromium reports `Notification.permission` as "denied" even where playwright has
|
||||
// granted the permission at the browser level, so the grant is reflected into the API the app
|
||||
// reads.
|
||||
// Headless Chromium reports Notification.permission as "denied" whatever playwright granted.
|
||||
if (options.context?.permissions?.includes("notifications")) {
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(Notification, "permission", {
|
||||
|
|
@ -291,8 +276,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
|
||||
return boot(context, {
|
||||
user,
|
||||
nip07: Boolean(options.nip07),
|
||||
path,
|
||||
env: options.env,
|
||||
checked: options.checked,
|
||||
relays: urls,
|
||||
indexers: indexerUrls,
|
||||
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
|
||||
// already cleared throws on unmount, and that is the fault the suite was blindest to.
|
||||
// Closed before anything is read off it: an $effect teardown reading a cleared binding throws.
|
||||
for (const {context} of contexts) {
|
||||
await context.close()
|
||||
}
|
||||
|
||||
// Before the assertions below, which throw: a test that fails still owes the next one a
|
||||
// container, and with every page already closed there is nothing left for the recreate's
|
||||
// network churn to interrupt.
|
||||
// Before the assertions below, which throw: a failing test still owes the next one a container.
|
||||
await zooid.reset()
|
||||
|
||||
if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) {
|
||||
// A path rather than a body: the list reporter truncates an inline attachment, and the
|
||||
// nightly run on the box keeps test-results and nothing else.
|
||||
// A path rather than a body, since the list reporter truncates an inline attachment.
|
||||
const consolePath = testInfo.outputPath("browser-console.log")
|
||||
|
||||
await writeFile(consolePath, faults.log.join("\n"))
|
||||
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
|
||||
// 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.
|
||||
// Whether a user ever had an event, which tells a client that dropped one from a relay that never sent it.
|
||||
const transcriptPath = testInfo.outputPath("relay-transcript.log")
|
||||
|
||||
await writeFile(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ export type TestUser = {
|
|||
signer: Nip01Signer
|
||||
}
|
||||
|
||||
// Every identity this process can sign as. zooid authenticates each write and refuses an event
|
||||
// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here.
|
||||
// zooid refuses an event whose author is not the authenticated pubkey, so seeding looks the author up here.
|
||||
export const testUsersByPubkey = new Map<string, TestUser>()
|
||||
|
||||
const makeUser = (name: string, secret: string): TestUser => {
|
||||
|
|
@ -21,8 +20,7 @@ const makeUser = (name: string, secret: string): TestUser => {
|
|||
return user
|
||||
}
|
||||
|
||||
// The secrets never leave the test process. They are stable across runs, so a pubkey can be
|
||||
// asserted on directly, and the leading nibbles name an author in a failed-assertion diff.
|
||||
// Stable across runs, so a pubkey can be asserted on and its leading nibbles name an author in a diff.
|
||||
export const users = {
|
||||
alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"),
|
||||
bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"),
|
||||
|
|
@ -30,15 +28,10 @@ export const users = {
|
|||
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
|
||||
}
|
||||
|
||||
// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into
|
||||
// that range rather than rejected when it falls outside.
|
||||
// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into it.
|
||||
const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** An identity beyond the four above, derived from its name so its pubkey is stable, and registered on minting. */
|
||||
export const makeTestUser = (name: string) => {
|
||||
const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex"))
|
||||
const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type {BrowserContext} from "@playwright/test"
|
||||
|
||||
// State the harness keeps per browser context, outside the browser: recorded traffic, blocked
|
||||
// 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.
|
||||
// Reading one before the route installer that owns it has run is a spec calling things out of order.
|
||||
export const makeContextStore = <T>(installer: string) => {
|
||||
const byContext = new WeakMap<BrowserContext, T>()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {tenantByUrl} from "../zooid/config"
|
|||
import {requestZooid} from "../zooid/transport"
|
||||
import {makeContextStore} from "./context"
|
||||
|
||||
// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because
|
||||
// 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.
|
||||
// Mirrors the service urls in src/app/env.ts, which reads import.meta.env and pulls in Capacitor.
|
||||
const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social"
|
||||
const PUSH_SERVER_ORIGIN = "https://nps.flotilla.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.
|
||||
const OPENROUTER_ORIGIN = "https://openrouter.ai"
|
||||
|
||||
// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts
|
||||
// the navigation, so a spec sees the redirect without one leaving.
|
||||
// .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
|
||||
const CHECKOUT_ORIGIN = "https://checkout.test"
|
||||
|
||||
// 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",
|
||||
)
|
||||
|
||||
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it
|
||||
// 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.
|
||||
// The dev server. Traffic to it is the app loading itself, so it is the one host both layers pass.
|
||||
export const isDevServerUrl = (url: URL) =>
|
||||
url.port === (process.env.E2E_PORT ?? "1847") &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
||||
|
|
@ -55,18 +50,11 @@ export type BlockedRequest = {
|
|||
|
||||
const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes")
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Blocks every http request but the dev server's and a relay's own origin. Install this first. */
|
||||
export const installHttpRoutes = (context: BrowserContext) => {
|
||||
const blocked = blockedStore.set(context, [])
|
||||
|
||||
// The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is
|
||||
// hundreds of module requests, and none of them is egress.
|
||||
// A sveltekit page in dev is hundreds of module requests, and none of them is egress.
|
||||
return context.route(
|
||||
url => !isDevServerUrl(url),
|
||||
async route => {
|
||||
|
|
@ -94,12 +82,7 @@ export const installHttpRoutes = (context: BrowserContext) => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Opt-in: some of what a page asks for is meant to be refused, so call it from a spec rather than teardown. */
|
||||
export const assertNoBlockedRequests = (context: BrowserContext) => {
|
||||
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.
|
||||
export type RelayInfoOverrides = Record<string, object>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A relay's real document with a few fields replaced. It is read at startup, so install it before navigating. */
|
||||
export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
|
||||
const overrideByOrigin = new Map(
|
||||
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 override = overrideByOrigin.get(origin)
|
||||
|
||||
// The nip-86 management api posts to this same path, and what it answers decides what the
|
||||
// admin ui offers, so only the document request is touched.
|
||||
// The nip-86 management api posts to this same path, so only the document request is touched.
|
||||
if (request.method() === "GET" && host && override) {
|
||||
const {status, headers, body} = await requestZooid(host, "GET", "/", {
|
||||
...request.headers(),
|
||||
|
|
@ -151,8 +126,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr
|
|||
|
||||
return route.fulfill({
|
||||
status,
|
||||
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own
|
||||
// headers are kept, all but the length of a body that is about to change.
|
||||
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's headers are kept.
|
||||
headers: omit(["content-length"], headers),
|
||||
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 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.
|
||||
*/
|
||||
/** The analytics script src/app.html loads. An empty body leaves window.plausible as the queueing shim. */
|
||||
export const mockAnalytics = (context: BrowserContext) =>
|
||||
context.route(`${PLAUSIBLE_ORIGIN}/**`, route =>
|
||||
route.fulfill({contentType: "application/javascript", body: ""}),
|
||||
|
|
@ -183,12 +153,7 @@ export type DufflepudFixtures = {
|
|||
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a
|
||||
* `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one calls this again
|
||||
* with its own. Playwright matches the most recently registered route first, so the spec's answers
|
||||
* win over the empty defaults.
|
||||
*/
|
||||
/** Dufflepud, the one service url the app hard-codes. `as()` installs it with no fixtures, so a spec calls this again. */
|
||||
export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
|
||||
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
|
||||
const {pathname} = new URL(route.request().url())
|
||||
|
|
@ -214,12 +179,10 @@ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtur
|
|||
return route.fallback()
|
||||
})
|
||||
|
||||
// The rate the harness writes its silence at. Nothing downstream reads it: the app decodes what it
|
||||
// is answered and writes its own header from the result.
|
||||
// The app decodes what it is answered and writes its own header from the result.
|
||||
const SPEECH_RATE = 24000
|
||||
|
||||
// Silence as a wav, built rather than inlined so a spec can ask for a length and then assert the
|
||||
// duration the player reads off it.
|
||||
// Built rather than inlined, so a spec can ask for a length and assert the duration read off it.
|
||||
const silence = (seconds: number) => {
|
||||
const bytes = seconds * SPEECH_RATE * 2
|
||||
const wav = Buffer.alloc(44 + bytes)
|
||||
|
|
@ -240,16 +203,10 @@ const silence = (seconds: number) => {
|
|||
return wav
|
||||
}
|
||||
|
||||
// The format the app asks the real endpoint for. The harness has no mp3 encoder, so it answers a
|
||||
// wav instead — the app decodes either, and only an answer a browser can decode exercises that.
|
||||
// The harness has no mp3 encoder, so it answers a wav, which the app decodes either way.
|
||||
const SPEECH_FORMAT = "mp3"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** OpenRouter's text to speech, answering every request with the same silence. Raw pcm is refused. */
|
||||
export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => {
|
||||
const spoken: string[] = []
|
||||
|
||||
|
|
@ -272,18 +229,14 @@ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3)
|
|||
}
|
||||
|
||||
export type Transcription = {
|
||||
// The name each recording was uploaded under, in the order it was uploaded. OpenRouter picks a
|
||||
// decoder off the extension, so this is how a spec reads what the recorder produced.
|
||||
// OpenRouter picks a decoder off the extension, so this is how a spec reads what the recorder made.
|
||||
uploads: string[]
|
||||
// Stops answering. A request that arrives while this is on is held open until `release`, which
|
||||
// is what a spec needs to watch a transcription that is still out.
|
||||
// Stops answering. A request that arrives while this is on is held open until `release`.
|
||||
hold(): 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 (
|
||||
context: BrowserContext,
|
||||
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
|
||||
// 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.
|
||||
// getBlossomServer probes the space's own origin, then the user's kind-10063 list, then this.
|
||||
export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
export type BlossomOptions = {
|
||||
|
|
@ -327,27 +278,18 @@ export type BlossomOptions = {
|
|||
server: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** What a spec can turn on. Each of these is off by default. */
|
||||
export type BlossomHandle = {
|
||||
// The same server, blobs included, in another user's context. A conversation's image is uploaded
|
||||
// encrypted and decrypted by the recipient, so the bytes one end puts in have to be the bytes the
|
||||
// other reads back.
|
||||
// A conversation's image is uploaded encrypted, so one end's bytes have to be the other's.
|
||||
install(context: BrowserContext): Promise<void>
|
||||
// Holds every upload open until `release`, which makes the in-flight state a fact rather than a
|
||||
// race against a mock that answers in a microtask.
|
||||
// Holds every upload open until `release`, which makes the in-flight state a fact rather than a race.
|
||||
hold(): void
|
||||
release(): void
|
||||
// Refuses every upload probe from here on, with a reason the app has to show.
|
||||
refuse(reason: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A blossom server that keeps what it was given, hashing an upload exactly as the real thing would. */
|
||||
export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => {
|
||||
const {origin} = new URL(server)
|
||||
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())
|
||||
|
||||
if (pathname === "/upload") {
|
||||
// BUD-06, which the app asks before spending an upload. A refusal has to expose its
|
||||
// reason cross-origin or all the toast can say is the status code.
|
||||
// BUD-06, which the app asks before spending an upload. A refusal exposes its reason cross-origin.
|
||||
if (method === "HEAD") {
|
||||
if (refusal) {
|
||||
return route.fulfill({
|
||||
|
|
@ -424,10 +365,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti
|
|||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The push server. Only the native adapters talk to it, so a browser run reaching this is a regression. */
|
||||
export const mockPushServer = (context: BrowserContext) =>
|
||||
context.route(`${PUSH_SERVER_ORIGIN}/**`, route => {
|
||||
const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean)
|
||||
|
|
@ -437,8 +375,7 @@ export const mockPushServer = (context: BrowserContext) =>
|
|||
return route.fulfill({json: {}})
|
||||
}
|
||||
|
||||
// Registration is only usable if it comes back with both. The relay is told to post
|
||||
// notifications to the callback rather than the client ever fetching it.
|
||||
// The relay is told to post notifications to the callback rather than the client ever fetching it.
|
||||
const key = "test-push-subscription"
|
||||
|
||||
return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}})
|
||||
|
|
@ -447,26 +384,20 @@ export const mockPushServer = (context: BrowserContext) =>
|
|||
return route.fallback()
|
||||
})
|
||||
|
||||
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left
|
||||
// as loose objects here so that mocking a payment flow doesn't pull the app's module graph, and
|
||||
// with it import.meta.env, into the node process. A relay record may also carry `members` and
|
||||
// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from.
|
||||
// One record off the hosting api, left loose so mocking a flow doesn't pull import.meta.env in.
|
||||
export type HostingRecord = Record<string, unknown>
|
||||
|
||||
// The backend's state when the page opens. Everything after that is what the app wrote, plus
|
||||
// whatever the handle below changed.
|
||||
// The backend's state when the page opens.
|
||||
export type HostingFixtures = {
|
||||
plans?: HostingRecord[]
|
||||
// Provisioning runs on every login and ignores what it gets back, so the empty default carries
|
||||
// any spec that isn't actually exercising the hosting ui.
|
||||
// Provisioning runs on every login and ignores what it gets back.
|
||||
tenant?: HostingRecord
|
||||
relays?: HostingRecord[]
|
||||
invoices?: HostingRecord[]
|
||||
draftInvoice?: HostingRecord
|
||||
}
|
||||
|
||||
// The backend changing its mind between two of the user's clicks, such as a custom domain that
|
||||
// verifies or an invoice that gets paid. It is the half of those flows no click can reach.
|
||||
// The backend changing its mind between two of the user's clicks, which no click can reach.
|
||||
export type HostingHandle = {
|
||||
setTenant(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)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The hosting api as a small stateful fake. Each browser context gets its own store. */
|
||||
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
|
||||
const plans = fixtures.plans ?? []
|
||||
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_verified: 0,
|
||||
...body,
|
||||
// A created space is opened straight away, so its url has to be one the container
|
||||
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
|
||||
// A created space is opened straight away, so its url has to be one the container serves.
|
||||
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 ?? []}}})
|
||||
}
|
||||
|
||||
// The dump is whatever the fixture left on the record, served as the download itself rather
|
||||
// than in a json envelope.
|
||||
// The dump is served as the download itself rather than in a json envelope.
|
||||
if (sub === "export") {
|
||||
return route.fulfill({
|
||||
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
|
||||
// ones that don't look signed, which is the shape zooid reports problems in.
|
||||
// The fake counts the lines and refuses the ones that don't look signed, as zooid reports them.
|
||||
if (sub === "import") {
|
||||
const lines = (request.postData() ?? "").split("\n").filter(line => line.trim())
|
||||
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}`}}})
|
||||
}
|
||||
|
||||
// GET and reconcile both answer with the invoice as it now stands, which is how a spec marks
|
||||
// one paid. Flip `paid_at` with the handle and let the dialog's next poll find it.
|
||||
// GET and reconcile both answer with the invoice as it stands, which is how a spec marks one paid.
|
||||
return route.fulfill({json: {data: invoice}})
|
||||
}
|
||||
|
||||
|
|
@ -671,8 +593,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
|
|||
}
|
||||
|
||||
export type LivekitOptions = {
|
||||
// Where the client is told to connect. Point it at something the test owns, since the token this
|
||||
// hands out is accepted by nothing else.
|
||||
// Point it at something the test owns, since the token this hands out is accepted by nothing else.
|
||||
serverUrl: string
|
||||
token?: string
|
||||
}
|
||||
|
|
@ -694,10 +615,30 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
|
|||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Serves a png for anything the browser is loading as an image, so a scenario's fixtures can
|
||||
* reference avatar, banner, blob and poster urls without any of them being fetched.
|
||||
*/
|
||||
// One row of Photon's answer. Anything but a city, state or country is left out of the results.
|
||||
export type GeocodedPlace = {
|
||||
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) =>
|
||||
context.route(
|
||||
url => !isDevServerUrl(url),
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ type Traffic = {
|
|||
leaks: Set<string>
|
||||
forgotten: Set<string>
|
||||
silenced: Set<string>
|
||||
eoseless: Set<string>
|
||||
}
|
||||
|
||||
const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes")
|
||||
|
||||
// A relay that holds nothing. REQs get an immediate EOSE and events are accepted and dropped, so a
|
||||
// leak fails on the assertion that names it rather than on a timeout three layers away.
|
||||
// A relay that holds nothing, so a leak fails on the assertion naming it rather than on a timeout.
|
||||
const openEmptyRelay = (): RelayConnection => {
|
||||
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
|
||||
// the fault a client cannot see, since a request it never answers is indistinguishable from one it
|
||||
// is still working on, and the only way out is the caller's own deadline.
|
||||
// A relay that serves what it has and never says it is done, so a request runs out its own deadline.
|
||||
const openEoselessRelay = (connection: RelayConnection): RelayConnection => ({
|
||||
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 => ({
|
||||
onMessage() {},
|
||||
send() {},
|
||||
|
|
@ -75,7 +86,9 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
|||
const relay = zooid.relays.get(url)
|
||||
|
||||
if (relay) {
|
||||
return relay.connect()
|
||||
const connection = relay.connect()
|
||||
|
||||
return traffic.eoseless.has(url) ? openEoselessRelay(connection) : connection
|
||||
}
|
||||
|
||||
traffic.leaks.add(url)
|
||||
|
|
@ -100,21 +113,14 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
|||
route.onClose(() => connection.close())
|
||||
}
|
||||
|
||||
/**
|
||||
* The single interception point for relay traffic. It goes on the context rather than a page, so
|
||||
* every page in it is covered including ones opened later, and it is safe to call before any page
|
||||
* exists.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** The single interception point for relay traffic. On the context, so it covers a page opened later. */
|
||||
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
|
||||
const traffic = trafficStore.set(context, {
|
||||
transcript: [],
|
||||
leaks: new Set(),
|
||||
forgotten: new Set(),
|
||||
silenced: new Set(),
|
||||
eoseless: new Set(),
|
||||
})
|
||||
|
||||
return context.routeWebSocket(
|
||||
|
|
@ -125,8 +131,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
|
|||
|
||||
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
|
||||
// published to three relays is three entries.
|
||||
// Every event this context put on the wire, oldest first. One event on three relays is three entries.
|
||||
export const getPublished = (context: BrowserContext): PublishedEvent[] =>
|
||||
getTranscript(context)
|
||||
.filter(
|
||||
|
|
@ -134,28 +139,25 @@ export const getPublished = (context: BrowserContext): PublishedEvent[] =>
|
|||
)
|
||||
.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
|
||||
// what it rendered.
|
||||
// The same, narrowed to one kind, for asking what the client published rather than what it rendered.
|
||||
export const getPublishedEvents = (context: BrowserContext, kind: number) =>
|
||||
getPublished(context)
|
||||
.filter(({event}) => event.kind === kind)
|
||||
.map(({event}) => event)
|
||||
|
||||
// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve`
|
||||
// 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.
|
||||
// `serve` resolves a relay once, at open, so a socket already open keeps its relay until a reload.
|
||||
export const forgetRelay = (context: BrowserContext, url: string) =>
|
||||
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
|
||||
// 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.
|
||||
// Resolved at open, like forgetRelay, so a page that boots into the fault passes `silent` to `as`.
|
||||
export const silenceRelay = (context: BrowserContext, url: string) =>
|
||||
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
|
||||
// actually said, and to whom.
|
||||
// Resolved at open too, so a page that boots into it passes `eoseless` to `as`.
|
||||
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) =>
|
||||
getTranscript(context)
|
||||
.map(
|
||||
|
|
|
|||
|
|
@ -8,11 +8,7 @@ import type {TestUser} from "../keys"
|
|||
import {makePublisher} from "./publish"
|
||||
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** One public relay's fixtures. Unlike a space it holds what a client reaches for by pubkey rather than by space. */
|
||||
export type SeededOpenRelay = {
|
||||
readonly name: OpenRelayName
|
||||
// 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[]
|
||||
note(user: TestUser, content: string, 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
|
||||
// appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only
|
||||
// identifies to relays that list names.
|
||||
// A relay a scenario expects the client to read from has to appear in the reader's own list.
|
||||
relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent
|
||||
follows(user: TestUser, follows: TestUser[], 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
|
||||
// space's `kind()` does.
|
||||
// This relay's domain kinds, bound to a resolver that answers with its url.
|
||||
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
|
||||
factory: KindFactory<R, W, Q>,
|
||||
): ConfiguredKind<R, W, Q>
|
||||
|
|
|
|||
|
|
@ -5,20 +5,16 @@ import type {TestUser} from "../keys"
|
|||
// A queued write, drained in declaration order by `seed` in scenario.ts.
|
||||
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
|
||||
// return before anything is written, so the event is filled in when its turn in the queue comes up.
|
||||
// Seeding calls record what to write and return before anything is written.
|
||||
export type SeededEvent = {
|
||||
readonly event: SignedEvent
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs
|
||||
// a relay url to resolve its hints against, and a relay has none until the queue has drained, so
|
||||
// anything built by one has to be deferred.
|
||||
// A domain writer needs a relay url to resolve its hints against, so anything it builds is deferred.
|
||||
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
|
||||
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
|
||||
// `write` is what an outbox-routed load resolves to, `read` is what a feed asks for that pubkey.
|
||||
export type RelayListUrls = {
|
||||
read?: 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.
|
||||
name: string
|
||||
enqueue: Enqueue
|
||||
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
|
||||
// rather than with the wall clock, so two fixtures describing the same thing cannot land
|
||||
// seconds apart and decide which of them wins.
|
||||
// The moment the scenario began. A fixture with no timestamp is stamped with it, not the wall clock.
|
||||
startedAt: number
|
||||
// How an event this process signs reaches the relay, deferred because a space's relay handle
|
||||
// only exists once the queue has started draining.
|
||||
// Deferred, since a space's relay handle only exists once the queue has started draining.
|
||||
sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent>
|
||||
}
|
||||
|
||||
// The queue every seeding call goes through: what to write is recorded now and published when
|
||||
// `seed()` drains, and what it produced only reads back after that.
|
||||
// What to write is recorded now and published when `seed()` drains.
|
||||
export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => {
|
||||
// Queues a write and hands back a getter for whatever it produced.
|
||||
const seeded = <T>(write: () => Promise<T>) => {
|
||||
|
|
|
|||
|
|
@ -11,16 +11,13 @@ import type {SeededSpace} from "./space"
|
|||
import {seedOpenRelay} from "./openRelay"
|
||||
import type {SeededOpenRelay} from "./openRelay"
|
||||
|
||||
// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two
|
||||
// hours before the test began, count-first like int and ago.
|
||||
// A fixture timestamp as an offset from the scenario's start. `at(2, HOUR)` is two hours before it.
|
||||
export type At = (count: number, unit: number) => number
|
||||
|
||||
export type SeedTools = {
|
||||
// Names a space the container already serves. Its policy is its toml in zooid/docker/config.
|
||||
relay: (name: SpaceName) => SeededSpace
|
||||
// Names one of the public relays, which is where the follow graph lives: `indexer` is what a
|
||||
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
|
||||
// ARCHITECTURE.md, "The follow graph".
|
||||
// Names one of the public relays, where the follow graph lives. See ARCHITECTURE.md, "The follow graph".
|
||||
open: (name: OpenRelayName) => SeededOpenRelay
|
||||
user: typeof users
|
||||
at: At
|
||||
|
|
@ -31,14 +28,11 @@ export type Scenario = {
|
|||
readonly at: At
|
||||
// The spaces this scenario seeded, which are the relays the app is handed as its own.
|
||||
readonly urls: string[]
|
||||
// What a pubkey's own lists are resolved from: the open indexer when a scenario declared one,
|
||||
// and the spaces otherwise, which is what a scenario that knows nothing about open relays gets.
|
||||
// The open indexer when a scenario declared one, and the spaces otherwise.
|
||||
readonly indexerUrls: string[]
|
||||
space(name: SpaceName): SeededSpace
|
||||
open(name: OpenRelayName): SeededOpenRelay
|
||||
// The events a returning user's client would already have on disk: their room list, and their
|
||||
// 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".
|
||||
// A relay won't serve the list that would tell authPolicy it may identify to it.
|
||||
cache(user: TestUser): SignedEvent[]
|
||||
}
|
||||
|
||||
|
|
@ -74,16 +68,14 @@ export const seed = async (
|
|||
|
||||
await build({relay, open, user: users, at})
|
||||
|
||||
// Seeding is async and fixtures depend on one another, so the builder only records what to
|
||||
// write. Draining the queue here publishes each fixture in the order it was declared.
|
||||
// Fixtures depend on one another, so the builder only records what to write and this drains it.
|
||||
for (const write of writes) {
|
||||
await write()
|
||||
}
|
||||
|
||||
const seeded = Array.from(spaces.values())
|
||||
|
||||
// A room list is replaceable and covers every space at once, so it can only be written after
|
||||
// all of them have been seeded.
|
||||
// A room list is replaceable and covers every space, so it is written after all of them are seeded.
|
||||
const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>()
|
||||
|
||||
for (const space of seeded) {
|
||||
|
|
|
|||
|
|
@ -23,20 +23,16 @@ import type {TestUser} from "../keys"
|
|||
import {makePublisher} 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,
|
||||
// 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.
|
||||
// @welshman/domain has no writer for NIP-29 kind-9 messages, so this pairs the base writer and reader.
|
||||
class MessageWriter extends EventWriter<BaseEventReader> {}
|
||||
|
||||
// The kind-14 a direct message really is. It is never published, since each participant gets it
|
||||
// inside a gift wrap, so this is what a spec asserts on.
|
||||
// The kind-14 a direct message really is. It is never published, since each participant gets a wrap.
|
||||
export type SeededRumor = {
|
||||
readonly rumor: HashedEvent
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
// A user's membership as their own client sees it, which the scenario turns into one room list
|
||||
// per user once every space has been seeded.
|
||||
// A user's membership as their own client sees it, turned into a room list once every space is seeded.
|
||||
export type SeededMembership = {
|
||||
user: TestUser
|
||||
rooms: string[]
|
||||
|
|
@ -48,26 +44,19 @@ export type SeededSpace = {
|
|||
readonly memberships: SeededMembership[]
|
||||
room(h: string, options?: RoomOptions): void
|
||||
member(user: TestUser, h?: string): void
|
||||
// Relay and room membership, plus a place in the user's own room list, which is what a user who
|
||||
// joined this space through the ui ends up with.
|
||||
// Relay and room membership, plus a place in the user's own room list.
|
||||
join(user: TestUser, ...rooms: string[]): void
|
||||
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
|
||||
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
|
||||
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
|
||||
// Where this user reads and writes, this space by default. Outbox routing resolves everything
|
||||
// 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.
|
||||
// Outbox routing resolves everything about a person through their relay list.
|
||||
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
|
||||
// is what makes a person reachable, so a story about messaging being off is a person without one.
|
||||
// Having one is what makes a person reachable, so messaging being off is a person without one.
|
||||
messagingRelayList(user: TestUser, urls?: string[], 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
|
||||
// sender, whose own copy is the half of the thread their client reads back.
|
||||
// One kind-14 rumor, gift-wrapped once per participant including the sender.
|
||||
dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor
|
||||
// This space's domain kinds, bound to a resolver that answers with its url, so a writer built
|
||||
// here renders its relay hints as this space. For everything `event()` takes a template for:
|
||||
// `space.event(user, () => space.kind(Article).writer().setTitle("x").renderTemplate())`.
|
||||
// This space's domain kinds, bound to a resolver that answers with its url.
|
||||
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
|
||||
factory: KindFactory<R, W, Q>,
|
||||
): ConfiguredKind<R, W, Q>
|
||||
|
|
@ -76,16 +65,14 @@ export type SeededSpace = {
|
|||
export type SeedSpaceOptions = {
|
||||
zooid: Zooid
|
||||
enqueue: Enqueue
|
||||
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
|
||||
// rather than with the wall clock.
|
||||
// A fixture declared without a timestamp is stamped with the scenario's start, not the wall clock.
|
||||
startedAt: number
|
||||
name: SpaceName
|
||||
}
|
||||
|
||||
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
|
||||
const memberships: SeededMembership[] = []
|
||||
// Known before seeding runs, unlike the relay handle below, so a fixture on another relay can
|
||||
// name this one.
|
||||
// Known before seeding runs, unlike the relay handle below, so another relay's fixture can name it.
|
||||
const url = tenantUrl(name)
|
||||
|
||||
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) =>
|
||||
publish(() => relay().message(user, h, content, createdAt))
|
||||
|
||||
// Flotilla replies in a room by quoting. Content.svelte renders a quote from the nostr uri in the
|
||||
// content rather than from the q tag, so the uri is prepended as prependParent does in
|
||||
// src/app/rooms.ts.
|
||||
// Content.svelte renders a quote from the nostr uri rather than the q tag, as prependParent does.
|
||||
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
|
||||
event(
|
||||
user,
|
||||
|
|
@ -188,9 +173,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) =>
|
||||
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
|
||||
// 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.
|
||||
// A gift wrap's author is an ephemeral key nobody here can authenticate as, so the sender sends it.
|
||||
const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => {
|
||||
const rumor = seeded(async () => {
|
||||
const writer = kind(DirectMessage).writer().setContent(content)
|
||||
|
|
|
|||
|
|
@ -1,48 +1,34 @@
|
|||
import {expect} 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, 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.
|
||||
*/
|
||||
/** What a spec names on screen. A locator more than one spec reaches for belongs here. */
|
||||
|
||||
export const dialog = (page: Page, title: string) =>
|
||||
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
|
||||
// alongside it.
|
||||
// The modal on top, for one with no heading of its own or one pushed over another.
|
||||
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
|
||||
// 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.
|
||||
// A modal is mounted alongside the page it covers, so both forms are in the dom at once.
|
||||
export const modalForm = (page: Page, title: string) =>
|
||||
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
|
||||
// of its own above it — a calendar event's rsvp buttons are one.
|
||||
// Named rather than counted, since a card is free to put a join of its own above it.
|
||||
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
|
||||
// has.
|
||||
// The menu is the one action with no accessible name, and the last thing in a card's last join.
|
||||
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
|
||||
// reachable through it. Searching rather than browsing avoids depending on which category tab an
|
||||
// emoji happens to live under.
|
||||
// The picker is a web component with an open shadow root, and searching avoids its category tabs.
|
||||
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
|
||||
await opener.click()
|
||||
|
||||
const picker = page.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
// Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the
|
||||
// picker from the last card is still visible alongside this one — so wait for there to be one
|
||||
// rather than reaching into whichever resolves first.
|
||||
// Tippy keeps a hidden popover mounted through its fade, so the last card's picker is still up.
|
||||
await expect(picker).toHaveCount(1)
|
||||
|
||||
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
|
||||
// the annotation is matched rather than the whole of it.
|
||||
// A result's label joins the emoji's name, its annotation and every shortcode.
|
||||
await picker.locator("input.search").fill(annotation)
|
||||
await picker
|
||||
.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.
|
||||
export const toast = (page: Page) => page.getByRole("alert")
|
||||
|
||||
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the
|
||||
// label at one end and the control at the other.
|
||||
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row.
|
||||
export const settingRow = (page: Page, label: string) =>
|
||||
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]")
|
||||
|
||||
// The editor is where the composer says whether it is ready. The send button is not there to ask
|
||||
// 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.
|
||||
// Only the conversation composer has a disabled state; a room's is usable as soon as it renders.
|
||||
export const composerEnabled = (page: Page) =>
|
||||
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")
|
||||
|
||||
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
|
||||
// the dom.
|
||||
// .room__content is column-reverse, so the message at the bottom of the room is first in the dom.
|
||||
export const messages = (page: Page) => page.locator(".room__item")
|
||||
|
||||
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) =>
|
||||
page.locator(".chat-bubble").filter({hasText: text})
|
||||
|
||||
// Typing into a room's composer and sending it. The composer is waited for rather than assumed,
|
||||
// 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.
|
||||
// Clicking a composer that already holds the caret would collapse the selection an edit types over.
|
||||
export const send = async (page: Page, content: string) => {
|
||||
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.
|
||||
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
|
||||
// different one.
|
||||
// The comment and thread composers are rich text rather than chat editors.
|
||||
export const noteEditor = (scope: Locator | Page) =>
|
||||
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
|
||||
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
|
||||
// A date as the browser formatted it. The options mirror dateFormatter in @welshman/lib.
|
||||
export const longDate = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,16 @@
|
|||
/**
|
||||
* 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 virtual relays the container serves. .test is reserved by rfc 2606, so none of them resolve.
|
||||
|
||||
// The relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla
|
||||
// space is.
|
||||
// The relays a space is seeded on: members-only, with nip-29 groups on.
|
||||
export const spaceTenants = {
|
||||
space: "space.test",
|
||||
other: "other.test",
|
||||
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite,
|
||||
// `unsigned` serves events with their signatures stripped, `delegated` gives every member one
|
||||
// management method.
|
||||
// Policy space.toml cannot express at the same time: no invite, stripped signatures, delegated management.
|
||||
closed: "closed.test",
|
||||
unsigned: "unsigned.test",
|
||||
delegated: "delegated.test",
|
||||
} as const
|
||||
|
||||
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a
|
||||
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
|
||||
// ARCHITECTURE.md, "The follow graph".
|
||||
// Public relays with no groups, where anything outside a space lives. See ARCHITECTURE.md, "The follow graph".
|
||||
export const openTenants = {
|
||||
indexer: "indexer.test",
|
||||
outbox: "outbox.test",
|
||||
|
|
|
|||
|
|
@ -20,39 +20,27 @@ import type {PublishOptions} from "./types"
|
|||
import {connectToZooid, port, requestZooid} 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
|
||||
// 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.
|
||||
// Pinned by digest so a spec runs against one zooid. ZOOID_IMAGE is how an unreleased build is tried.
|
||||
const image =
|
||||
process.env.ZOOID_IMAGE ??
|
||||
"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
|
||||
// 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.
|
||||
// How long after a recreate chromium can still abort what a page has in flight.
|
||||
const SETTLE = 2500
|
||||
|
||||
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
|
||||
|
||||
const configSource = fileURLToPath(new URL("docker/config", import.meta.url))
|
||||
|
||||
// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call
|
||||
// edits that relay's name, description or icon. 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.
|
||||
// zooid saves a relay's toml back on a nip-86 edit, so it is handed a copy rather than the repo's.
|
||||
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is
|
||||
// invisible to it however well it works when typed. Name the executable to drive with E2E_DOCKER in
|
||||
// that case.
|
||||
// execFile does not go through a shell, so a docker that exists only as an alias needs E2E_DOCKER.
|
||||
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
|
||||
|
||||
// Whether something already answers on the port the container publishes, asked before this run
|
||||
// brings its own up.
|
||||
// Whether something already answers on the port the container publishes.
|
||||
const isRelayPortTaken = () =>
|
||||
new Promise<boolean>(resolve => {
|
||||
const socket = createConnection({port, host: "127.0.0.1"})
|
||||
|
|
@ -69,9 +57,7 @@ const isRelayPortTaken = () =>
|
|||
socket.once("error", () => resolve(false))
|
||||
})
|
||||
|
||||
// Every invocation carries the config directory and the image, `down` included. The compose file
|
||||
// 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.
|
||||
// The compose file names both without a default, so a call that left one out fails to interpolate.
|
||||
const docker = (...args: string[]) =>
|
||||
execFileAsync(dockerCommand, args, {
|
||||
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)
|
||||
|
||||
// Why the container cannot be driven, or undefined when it can. A cli that is not on this process's
|
||||
// PATH and a daemon that is not running need different answers, so they are reported apart rather
|
||||
// than as one boolean.
|
||||
// Why the container cannot be driven, or undefined when it can.
|
||||
const probeDocker = async () => {
|
||||
try {
|
||||
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
|
||||
// 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.
|
||||
// How far the container's clock is from this host's, in seconds. Positive means the container is behind.
|
||||
const getClockDrift = async (host: string) => {
|
||||
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
|
||||
// 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.
|
||||
// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go).
|
||||
const describeClockDrift = (drift: number) => {
|
||||
const [minutes, direction] =
|
||||
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>>>
|
||||
|
||||
// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the
|
||||
// terminal, because a skip reason otherwise reaches only the html report.
|
||||
// Also written to the terminal, since a skip reason otherwise reaches only the html report.
|
||||
export const describeDockerProblem = () =>
|
||||
(problem ??= probeDocker().then(reason => {
|
||||
if (reason) {
|
||||
|
|
@ -164,14 +143,9 @@ export const describeDockerProblem = () =>
|
|||
return reason
|
||||
}))
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The relay every test runs against. One container serves a virtual relay per entry in `tenants`. */
|
||||
export class Zooid {
|
||||
// Every socket into the container, the client's and seeding's alike, is one this process opened,
|
||||
// so this map is also the definition of a url that is not a leak.
|
||||
// Every socket into the container is one this process opened, so this map also defines a url that is not a leak.
|
||||
relays = new Map(
|
||||
tenantNames.map(name => [
|
||||
tenantUrl(name),
|
||||
|
|
@ -189,8 +163,7 @@ export class Zooid {
|
|||
// When the last recreate's interface churn stops being able to abort a request.
|
||||
private settledAt = 0
|
||||
|
||||
// Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are
|
||||
// free, so the fixture can call this per test.
|
||||
// Verifies docker rather than bringing the container up, which reset does. Repeat calls are free.
|
||||
start = async () => {
|
||||
if (this.started) {
|
||||
return
|
||||
|
|
@ -246,11 +219,7 @@ export class Zooid {
|
|||
}
|
||||
}
|
||||
|
||||
// Leaves a fresh container for whoever runs next. Recreating one churns the host's network
|
||||
// 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.
|
||||
// Recreating churns the host's network interfaces, and chromium aborts what it has in flight.
|
||||
reset = async () => {
|
||||
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
|
||||
// test's fixtures and its seeding are all inside the window already, so most tests pay nothing
|
||||
// here.
|
||||
// As late as the wait can be left: teardown, the next test's fixtures and its seeding are inside it.
|
||||
settle = async () => {
|
||||
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
|
||||
// was written against, and a machine that already has it never gets here.
|
||||
// A pull of a pinned reference can only produce the relay this suite was written against.
|
||||
private fetchImage = async () => {
|
||||
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
|
||||
// without this a startup failure is a status code and no reason.
|
||||
// Compose reports only that a container exited, so without this a startup failure has no reason.
|
||||
private logs = () =>
|
||||
compose("logs", "--no-color", "--tail", "50").then(
|
||||
({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)
|
||||
}
|
||||
|
||||
// Storage is tmpfs with no volume mounted for it, so a new container is a new database and
|
||||
// recreating is what makes this a reset.
|
||||
// Storage is tmpfs with no volume mounted for it, so a new container is a new database.
|
||||
private up = async () => {
|
||||
// A fresh copy on every recreate is what restores relay metadata a test edited through nip-86.
|
||||
// The modes are permissive because the container runs as uid 65532 while the copy belongs to
|
||||
// whoever ran the suite, and a runtime that keeps host ownership leaves zooid unable to write
|
||||
// the file it was told to save.
|
||||
// A fresh copy restores metadata a test edited through nip-86, and the container runs as uid 65532.
|
||||
await rm(configDir, {recursive: true, force: true})
|
||||
await cp(configSource, configDir, {recursive: true})
|
||||
await chmod(configDir, 0o777)
|
||||
|
|
@ -348,8 +309,7 @@ export class Zooid {
|
|||
const deadline = Date.now() + ms(30)
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still
|
||||
// loading. Anything a relay itself answers means every tenant is ready.
|
||||
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still loading.
|
||||
const isUp = await Promise.all(
|
||||
tenantNames.map(name =>
|
||||
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
|
||||
// url signed into the auth event is the one the client uses, since that is what khatru rebuilds
|
||||
// from the headers transport.ts sends.
|
||||
// NIP-42 binds an identity to a connection, and khatru rebuilds the signed url from transport.ts's headers.
|
||||
private authenticate = async (host: string, user: TestUser) => {
|
||||
const key = `${host} ${user.pubkey}`
|
||||
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 drift = await getClockDrift(host).catch(() => undefined)
|
||||
|
||||
// Nearly always the clock rather than the key. Every identity here is one zooid's toml names,
|
||||
// and the signature is checked after the timestamp the relay compares against its own.
|
||||
// Nearly always the clock: the signature is checked after the timestamp the relay compares against.
|
||||
if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) {
|
||||
throw new Error(`${summary}\n\n${describeClockDrift(drift)}`)
|
||||
}
|
||||
|
|
@ -412,8 +369,7 @@ export class Zooid {
|
|||
throw new Error(summary)
|
||||
}
|
||||
|
||||
// Both halves of seeding, the auth that opens a connection and every event written over it, are
|
||||
// answered with an OK naming the event that was sent.
|
||||
// Both the auth that opens a connection and every event written over it are answered with an OK.
|
||||
private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => {
|
||||
connection.send(message)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,7 @@ export type TestRelayOptions = {
|
|||
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
|
||||
}
|
||||
|
||||
// Seeding signs the events a real client would send and publishes them over a socket. Room
|
||||
// administration is signed by `admin`, the only test identity the tomls grant can_manage.
|
||||
//
|
||||
// Every timestamp is the caller's. 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.
|
||||
// admin is the only test identity the tomls grant can_manage, and every timestamp is the caller's.
|
||||
export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => {
|
||||
const event = async (user: TestUser, template: StampedEvent) => {
|
||||
const signed = await user.signer.sign(template)
|
||||
|
|
@ -37,8 +33,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay
|
|||
event,
|
||||
publish,
|
||||
room: async (h, meta, createdAt) => {
|
||||
// A relay stamps the metadata it derives from these ops with the op's own created_at, so
|
||||
// creation has to be strictly older than the edit or the edit is dropped as stale.
|
||||
// A relay stamps derived metadata with the op's created_at, so creation must be older than an edit.
|
||||
await event(
|
||||
users.admin,
|
||||
makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}),
|
||||
|
|
|
|||
|
|
@ -4,21 +4,7 @@ import {parseJson} from "@welshman/lib"
|
|||
import type {ClientMessage, RelayMessage} from "@welshman/net"
|
||||
import type {RelayConnection} from "./types"
|
||||
|
||||
/**
|
||||
* How the harness reaches the zooid container, and the only place that knows 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).
|
||||
*/
|
||||
/** The only place that knows the container has a loopback address. A relay selection drops a local url. */
|
||||
|
||||
// Must match the published port in zooid/docker/compose.yaml.
|
||||
export const port = 3334
|
||||
|
|
@ -26,13 +12,11 @@ export const port = 3334
|
|||
const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"})
|
||||
|
||||
export type ZooidConnection = RelayConnection & {
|
||||
// Resolves with the first message the relay sends that matches. Seeding waits for its auth
|
||||
// challenge and for the OK answering each write this way.
|
||||
// Resolves with the first message the relay sends that matches.
|
||||
wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage>
|
||||
}
|
||||
|
||||
// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry
|
||||
// a Host header, which fetch forbids, so the harness depends on `ws` for this alone.
|
||||
// Node's own WebSocket cannot carry a Host header, which fetch forbids, so this depends on `ws`.
|
||||
export const connectToZooid = (host: string): ZooidConnection => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)})
|
||||
const listeners = new Set<(message: RelayMessage) => void>()
|
||||
|
|
@ -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
|
||||
// and the `host` in the tenant's toml have drifted apart.
|
||||
// A 404 from zooid's dispatcher means `tenants` and the tenant toml's `host` have drifted apart.
|
||||
socket.on("error", error =>
|
||||
console.error(`The zooid container refused a socket for ${host}: ${error.message}`),
|
||||
)
|
||||
|
|
@ -62,8 +45,7 @@ export const connectToZooid = (host: string): ZooidConnection => {
|
|||
onMessage: listener => {
|
||||
listeners.add(listener)
|
||||
},
|
||||
// A client may speak before the container has finished its handshake. The page's socket is open
|
||||
// as soon as playwright has a route for it, well before this one is.
|
||||
// A client may speak before the container has finished its handshake.
|
||||
send: message => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
write(message)
|
||||
|
|
@ -92,8 +74,7 @@ export type ZooidResponse = {
|
|||
body: Buffer
|
||||
}
|
||||
|
||||
// One http request to the container, carrying the same two headers, so the nip-11 document and the
|
||||
// nip-86 management api the browser reads are the real relay's.
|
||||
// Carries the same two headers, so the nip-11 and nip-86 answers the browser reads are the relay's.
|
||||
export const requestZooid = (
|
||||
host: string,
|
||||
method: string,
|
||||
|
|
@ -109,8 +90,7 @@ export const requestZooid = (
|
|||
host: "127.0.0.1",
|
||||
port,
|
||||
headers: {
|
||||
// A pseudo-header describes the request line rather than the request, and node:http
|
||||
// rejects a header name with a colon in it.
|
||||
// A pseudo-header describes the request line, and node:http rejects a header name with a colon in it.
|
||||
...Object.fromEntries(
|
||||
Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")),
|
||||
),
|
||||
|
|
@ -122,8 +102,7 @@ export const requestZooid = (
|
|||
const responseHeaders: Record<string, string> = {}
|
||||
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
// Hop-by-hop headers describe the connection this answer arrived on, not the one the
|
||||
// browser is waiting on, which playwright frames itself.
|
||||
// Hop-by-hop headers describe the connection this answer arrived on, not the browser's.
|
||||
if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) {
|
||||
responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import type {ClientMessage, RelayMessage} from "@welshman/net"
|
|||
import type {TestUser} from "../keys"
|
||||
|
||||
export type PublishOptions = {
|
||||
// Which identity the seeding connection authenticates as. Defaults to the event's own author,
|
||||
// which then has to be a test identity. A gift wrap is signed by an ephemeral key nothing in this
|
||||
// process can authenticate as, so its connection belongs to the sender instead.
|
||||
// Which identity the seeding connection authenticates as, defaulting to the event's own author.
|
||||
as?: TestUser
|
||||
}
|
||||
|
||||
|
|
@ -17,9 +15,7 @@ export type RoomOptions = {
|
|||
private?: boolean
|
||||
}
|
||||
|
||||
// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call
|
||||
// publishes over a real socket rather than inserting into storage, so the relay stores exactly what
|
||||
// it would have stored for a real client.
|
||||
// Every seeding call publishes over a real socket, so the relay stores what it would for a client.
|
||||
export type TestRelay = {
|
||||
readonly name: string
|
||||
readonly url: string
|
||||
|
|
@ -29,13 +25,11 @@ export type TestRelay = {
|
|||
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
|
||||
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
|
||||
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
|
||||
// An event this process did not sign, sent over `as`'s connection. A gift wrap, whose author is
|
||||
// the ephemeral key that wrapped it, is the case that needs this.
|
||||
// An event this process did not sign, sent over `as`'s connection, which a gift wrap needs.
|
||||
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
|
||||
// browser context observes another's writes over the wire.
|
||||
// Every connection to a url reaches the same container, so one context observes another's writes.
|
||||
export type RelayConnection = {
|
||||
onMessage(listener: (message: RelayMessage) => void): void
|
||||
send(message: ClientMessage): void
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ import {
|
|||
|
||||
const ICON = gifFile("icon.gif")
|
||||
|
||||
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid
|
||||
// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the
|
||||
// only part of a card that differs between the two.
|
||||
// `free` is what RelayForm starts on. PricingTable names a plan by its member limit.
|
||||
const PLANS = [
|
||||
{id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false},
|
||||
{
|
||||
|
|
@ -29,7 +27,7 @@ const PLANS = [
|
|||
name: "Basic",
|
||||
amount: 500,
|
||||
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
|
||||
members: null,
|
||||
blossom: true,
|
||||
|
|
@ -37,8 +35,7 @@ const PLANS = [
|
|||
},
|
||||
]
|
||||
|
||||
// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it
|
||||
// calls .trim() and .replace() on several of them.
|
||||
// Every field RelayDetailCard reads is present, since it calls .trim() and .replace() on several.
|
||||
const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "relay-1",
|
||||
tenant_pubkey: users.alice.pubkey,
|
||||
|
|
@ -65,26 +62,18 @@ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
|
|||
...overrides,
|
||||
})
|
||||
|
||||
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page
|
||||
// that has opened two of them holds both — only the one on screen is visible. Exact, because
|
||||
// "Edit role" and "Edit roles" are two different menus in the same directory.
|
||||
// Tippy leaves a menu in the dom when it hides. Exact, since "Edit role" and "Edit roles" differ.
|
||||
const menuItem = (page: Page, name: string) =>
|
||||
page.getByRole("button", {name, exact: true}).filter({visible: true})
|
||||
|
||||
// Every space-level admin action hangs off the space menu, which opens from the header button in
|
||||
// the secondary nav. That button is labeled with the space's name and its host, and only the host
|
||||
// survives a rename.
|
||||
// That button is labeled with the space's name and its host, and only the host survives a rename.
|
||||
const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click()
|
||||
|
||||
// SpaceMember covers its card with a button whose aria-label is baked from the profile display at
|
||||
// first render — before the profile has loaded — so the card is found by the name it shows rather
|
||||
// than by that label. It is the only interactive card in the directory, and the member's own menu
|
||||
// is the last button inside it.
|
||||
// The card's aria-label is baked at first render, so the card is found by the name it shows.
|
||||
const memberCard = (page: Page, name: string) =>
|
||||
page.locator(".card-interactive").filter({hasText: name})
|
||||
|
||||
// Exact, because several permission names are a substring of another — "Ban members" of "Unban
|
||||
// members".
|
||||
// Exact, because several permission names are a substring of another: "Ban members" of "Unban members".
|
||||
const permission = (scope: Locator, name: string) =>
|
||||
scope.getByRole("checkbox", {name, exact: true})
|
||||
|
||||
|
|
@ -93,16 +82,12 @@ const openEventMenu = (card: Locator) => menuButton(card).click()
|
|||
const articleCard = (page: Page, title: string) =>
|
||||
page.locator('[data-component="ArticleItem"]').filter({hasText: title})
|
||||
|
||||
// A FieldInline puts its control in the div immediately after its label, which is how one row of
|
||||
// the hosting card is told apart from the others in the same grid.
|
||||
// A FieldInline puts its control in the div immediately after its label.
|
||||
const setting = (page: Page, label: string) =>
|
||||
page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div")
|
||||
|
||||
const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox")
|
||||
|
||||
// A hosted relay's status and its plan each render as a badge beside its name.
|
||||
const badge = (page: Page, text: string) => page.locator(".badge").filter({hasText: text})
|
||||
|
||||
test("US-092 edit a space's profile and featured content", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
|
@ -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")
|
||||
|
||||
await expect(editor).toBeVisible()
|
||||
|
||||
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 label rather than by writing to the input. Picking a file dismisses the picker.
|
||||
// The upload's file input is hidden inside its own label, so the chooser opens by clicking it.
|
||||
const picker = dialog(admin, "Add an image")
|
||||
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")
|
||||
.filter({has: admin.getByRole("heading", {name: "Featured"})})
|
||||
|
||||
await expect(featured.getByText("No featured content yet.")).toBeVisible()
|
||||
|
||||
await featured.getByRole("button").first().click()
|
||||
|
||||
const featuredEditor = dialog(admin, "Featured Content")
|
||||
|
|
@ -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(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")
|
||||
|
||||
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"})})
|
||||
|
||||
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}) => {
|
||||
|
|
@ -203,9 +177,6 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
|
|||
await menuItem(admin, "Manage Roles").click()
|
||||
|
||||
const roles = dialog(admin, "Manage Roles")
|
||||
const emptyState = roles.getByText("No roles yet. Create one to start organizing members.")
|
||||
|
||||
await expect(emptyState).toBeVisible()
|
||||
|
||||
await roles.getByRole("button", {name: "Create Role"}).click()
|
||||
|
||||
|
|
@ -245,12 +216,9 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
|
|||
|
||||
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")
|
||||
await expect(bobsBadge).toHaveAttribute("style", /--role-color: hsl\(200/)
|
||||
|
||||
// ...or from a member's own card, which is also how it comes back off
|
||||
// ...or from a member's own card
|
||||
await memberCard(admin, "Carol Cutter").getByRole("button").last().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(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun")
|
||||
|
||||
// The directory's search covers the roles a member holds as well as their name
|
||||
const term = admin.getByPlaceholder("Search people or roles...")
|
||||
|
||||
await term.fill("Bosun")
|
||||
|
||||
await expect(admin.locator(".card-interactive")).toHaveCount(2)
|
||||
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
||||
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
|
||||
|
||||
await term.fill("")
|
||||
|
||||
await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
|
||||
await menuItem(admin, "Edit roles").click()
|
||||
await carolsRoles.getByRole("checkbox").uncheck()
|
||||
await carolsRoles.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(admin.getByRole("alert")).toContainText("Roles updated!")
|
||||
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
|
||||
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0)
|
||||
|
||||
await admin.getByRole("button", {name: "More options"}).click()
|
||||
await menuItem(admin, "Manage Roles").click()
|
||||
await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click()
|
||||
await menuItem(admin, "Delete role").click()
|
||||
|
||||
const confirm = dialog(admin, "Delete Role")
|
||||
|
||||
await expect(
|
||||
confirm.getByText('Delete the "Bosun" role? Members will keep their space membership.'),
|
||||
).toBeVisible()
|
||||
|
||||
await confirm.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Delete Role").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByRole("alert")).toContainText("Role deleted!")
|
||||
await expect(emptyState).toBeVisible()
|
||||
|
||||
await roles.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
||||
await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0)
|
||||
|
||||
const bob = await as(users.bob, spacePath(url) + "/directory")
|
||||
|
||||
await expect(memberCard(bob, "Carol Cutter")).toBeVisible()
|
||||
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
|
||||
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0)
|
||||
})
|
||||
|
||||
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.profile(user.bob, {name: "Bob Barnacle"})
|
||||
|
||||
// Somebody who belongs to a different space, so adding her here is a change to this space's
|
||||
// member list rather than something the scenario already seeded.
|
||||
// Somebody who belongs to a different space, so adding her here is a change to this one.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.admin, "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")
|
||||
|
||||
// Arriving through the other space is what loads Nadia's profile: the invite dialog's search
|
||||
// reads the profiles this client already holds.
|
||||
const page = await as(users.admin, roomPath(other.url, "lounge"), {
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
// The invite dialog's search reads the profiles this client already holds.
|
||||
const page = await as(users.admin, roomPath(other.url, "lounge"))
|
||||
|
||||
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()
|
||||
|
||||
const invite = dialog(page, "Create an Invite")
|
||||
const link = invite.locator("input[readonly]")
|
||||
|
||||
await expect(invite.locator("canvas")).toBeVisible()
|
||||
await expect(link).toHaveValue(/\/join\?r=space\.test&c=.+/)
|
||||
await expect(invite.locator("input[readonly]")).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!")
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await link.inputValue())
|
||||
const addMembers = dialog(page, "Add Members")
|
||||
|
||||
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 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!")
|
||||
|
||||
// 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 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.getByRole("button", {name: "Create invite"}).click()
|
||||
|
||||
const roomInvite = dialog(page, "Create a Room Invite")
|
||||
|
||||
await expect(roomInvite.locator("canvas")).toBeVisible()
|
||||
await expect(roomInvite.locator("input[readonly]")).toHaveValue(
|
||||
await expect(dialog(page, "Create a Room Invite").locator("input[readonly]")).toHaveValue(
|
||||
/\/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")
|
||||
|
||||
// An ordinary member reads the same directory without any of the controls that manage it
|
||||
const bob = await as(users.bob, spacePath(url) + "/directory")
|
||||
const carolsCard = memberCard(bob, "Carol Cutter")
|
||||
|
||||
await expect(carolsCard).toBeVisible()
|
||||
await expect(carolsCard.getByRole("button")).toHaveCount(1)
|
||||
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
|
||||
|
||||
const admin = await as(users.admin, spacePath(url) + "/directory")
|
||||
|
||||
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
|
||||
await menuItem(admin, "Remove member").click()
|
||||
|
||||
const removal = dialog(admin, "Remove Member")
|
||||
|
||||
await expect(removal.getByText("Remove @Bob Barnacle from the space?")).toBeVisible()
|
||||
|
||||
await removal.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Remove Member").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!")
|
||||
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 menuItem(admin, "Ban member").click()
|
||||
|
||||
const ban = dialog(admin, "Ban Member")
|
||||
|
||||
await expect(ban.getByText("Ban @Carol Cutter from the space?")).toBeVisible()
|
||||
|
||||
await ban.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Ban Member").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!")
|
||||
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")
|
||||
|
||||
await expect(banned.getByText("Carol Cutter")).toBeVisible()
|
||||
|
||||
await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click()
|
||||
await menuItem(admin, "Restore User").click()
|
||||
|
||||
|
|
@ -491,38 +400,21 @@ test("US-096 moderate messages and posts", async ({seed, as}) => {
|
|||
|
||||
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"))
|
||||
|
||||
await expect(alice.getByText("the hull is patched")).toBeVisible()
|
||||
|
||||
await openMessageMenu(alice, "the hull is patched")
|
||||
|
||||
await expect(menuItem(alice, "Delete Message")).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: "Report Content", exact: true})).toHaveCount(0)
|
||||
|
||||
await alice.goto(spacePath(url) + "/articles")
|
||||
await openEventMenu(articleCard(alice, "Tides and Timetables"))
|
||||
|
||||
await expect(menuItem(alice, "Delete Article")).toBeVisible()
|
||||
|
||||
// Everybody else gets a report instead, and the report needs a reason
|
||||
// Everybody else gets a report instead
|
||||
const bob = await as(users.bob, roomPath(url, "general"))
|
||||
|
||||
await openMessageMenu(bob, "the hull is patched")
|
||||
|
||||
await expect(menuItem(bob, "Report Content")).toBeVisible()
|
||||
await expect(bob.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0)
|
||||
|
||||
await menuItem(bob, "Report Content").click()
|
||||
|
||||
const report = dialog(bob, "Report Content")
|
||||
|
||||
await report.getByRole("button", {name: "Send Report"}).click()
|
||||
|
||||
await expect(bob.getByRole("alert")).toContainText("Please select a reason for your report.")
|
||||
await expect(report).toBeVisible()
|
||||
|
||||
await report.getByRole("combobox").selectOption("Spam")
|
||||
await report.getByRole("button", {name: "Send Report"}).click()
|
||||
|
||||
|
|
@ -536,13 +428,7 @@ test("US-096 moderate messages and posts", async ({seed, as}) => {
|
|||
await openMessageMenu(admin, "aye captain")
|
||||
await menuItem(admin, "Delete Message").click()
|
||||
|
||||
const messageConfirm = dialog(admin, "Delete Message")
|
||||
|
||||
await expect(
|
||||
messageConfirm.getByText("Are you sure you want to delete this message from the space?"),
|
||||
).toBeVisible()
|
||||
|
||||
await messageConfirm.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Delete Message").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
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 menuItem(admin, "Delete Article").click()
|
||||
|
||||
const articleConfirm = dialog(admin, "Delete Article")
|
||||
|
||||
await expect(
|
||||
articleConfirm.getByText("Are you sure you want to delete this article from the space?"),
|
||||
).toBeVisible()
|
||||
|
||||
await articleConfirm.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Delete Article").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
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")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
// A room the relay will not admit anyone to on their own say-so, so a join request stays
|
||||
// pending instead of being granted the moment it lands.
|
||||
// A room the relay will not admit anyone to on their own say-so, so a join request stays pending.
|
||||
space.room("vault", {name: "Vault", closed: true, private: true})
|
||||
space.join(user.admin, "general", "vault")
|
||||
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 admin = await as(users.admin, spacePath(url) + "/about")
|
||||
const queueButton = admin.getByRole("button", {name: /^Action Items/})
|
||||
|
||||
await openSpaceMenu(admin)
|
||||
|
||||
await expect(queueButton).toHaveText("Action Items (0)")
|
||||
await expect(queueButton.locator(".bg-primary")).toHaveCount(0)
|
||||
|
||||
await admin.keyboard.press("Escape")
|
||||
|
||||
const report = async (page: Page, text: string, reason: string) => {
|
||||
await openMessageMenu(page, text)
|
||||
|
|
@ -636,11 +507,7 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
|
|||
await askToJoinVault(carol)
|
||||
|
||||
await openSpaceMenu(admin)
|
||||
|
||||
await expect(queueButton).toHaveText("Action Items (4)")
|
||||
await expect(queueButton.locator(".bg-primary")).toHaveCount(1)
|
||||
|
||||
await queueButton.click()
|
||||
await admin.getByRole("button", {name: /^Action Items/}).click()
|
||||
|
||||
const queue = dialog(admin, "Action Items")
|
||||
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 menuItem(admin, "Remove Content").click()
|
||||
|
||||
const removal = dialog(admin, "Remove Content")
|
||||
|
||||
await expect(
|
||||
removal.getByText("Are you sure you want to delete this content from the space?"),
|
||||
).toBeVisible()
|
||||
|
||||
await removal.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(admin, "Remove Content").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!")
|
||||
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 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}) => {
|
||||
|
|
@ -733,20 +586,6 @@ test("US-130 share out admin permissions", async ({seed, as}) => {
|
|||
const {url} = scenario.space("space")
|
||||
const admin = await as(users.admin, spacePath(url) + "/directory")
|
||||
|
||||
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "More options"}).click()
|
||||
await menuItem(admin, "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 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 menuItem(admin, "Admins").click()
|
||||
|
||||
const admins = dialog(admin, "Admins")
|
||||
|
||||
await expect(
|
||||
admins.locator(".card").filter({hasText: "Bob Barnacle"}).locator(".badge"),
|
||||
).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", {
|
||||
hosting: {plans: PLANS},
|
||||
// A space created here is opened at wss://<subdomain>.<domain>/, so the domain has to be one
|
||||
// the container serves and the subdomain has to name one of its tenants.
|
||||
// A space created here is opened at wss://<subdomain>.<domain>/, which has to name a tenant.
|
||||
env: {VITE_HOSTING_RELAY_DOMAIN: "test"},
|
||||
})
|
||||
|
||||
await expect(page.getByText("You don't host any spaces yet.")).toBeVisible()
|
||||
await expect(page.getByText("Create a space to get started.")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Create a Space"}).click()
|
||||
|
||||
const form = dialog(page, "New Space")
|
||||
|
|
@ -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()
|
||||
|
||||
await expect(listed).toContainText("Space")
|
||||
await expect(listed).toContainText("Active")
|
||||
await expect(listed).toContainText("Free")
|
||||
await expect(listed.getByRole("link", {name: "Manage"})).toBeVisible()
|
||||
|
||||
// A paid plan has to be paid for, so creation hands straight over to payment
|
||||
await page.getByRole("button", {name: "Create a Space"}).click()
|
||||
await form.getByPlaceholder("My Space").fill("Other")
|
||||
|
||||
await expect(form.getByPlaceholder("my-space")).toHaveValue("other")
|
||||
|
||||
await form.getByRole("button").filter({hasText: "Unlimited members"}).click()
|
||||
await form.getByRole("button", {name: "Create Space", exact: true}).click()
|
||||
|
||||
|
|
@ -901,25 +733,14 @@ test("US-099 configure a hosted relay", async ({seed, as}) => {
|
|||
|
||||
await expect(settingToggle(alice, "Public read")).toBeChecked()
|
||||
|
||||
// The free plan doesn't include either of these, and says so rather than failing on save
|
||||
const blossom = settingToggle(alice, "Media storage")
|
||||
|
||||
await expect(blossom).toBeDisabled()
|
||||
// The free plan doesn't include either of these
|
||||
await expect(settingToggle(alice, "Media storage")).toBeDisabled()
|
||||
await expect(settingToggle(alice, "LiveKit support")).toBeDisabled()
|
||||
|
||||
// The tooltip hangs off a wrapper rather than the input, so it still shows while it is disabled
|
||||
await blossom.locator("xpath=..").hover()
|
||||
|
||||
await expect(alice.getByText("Not available on your current plan").first()).toBeVisible()
|
||||
|
||||
// A member of the space who doesn't own it has nothing to manage here
|
||||
const bob = await as(users.bob, spacePath(url) + "/admin")
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Not a Coracle-hosted space"})).toBeVisible()
|
||||
await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/hosting",
|
||||
)
|
||||
await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toBeVisible()
|
||||
})
|
||||
|
||||
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 expect(badge(page, "Basic")).toBeVisible()
|
||||
await expect(setting(page, "Current plan")).toHaveText("Basic")
|
||||
|
||||
// The paid plan unlocks both gated features
|
||||
const blossom = settingToggle(page, "Media storage")
|
||||
const livekit = settingToggle(page, "LiveKit support")
|
||||
|
||||
await expect(blossom).toBeEnabled()
|
||||
|
||||
await blossom.click()
|
||||
await livekit.click()
|
||||
|
||||
|
|
@ -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 expect(page.getByRole("alert")).toContainText("Plan updated.")
|
||||
await expect(badge(page, "Free")).toBeVisible()
|
||||
await expect(setting(page, "Current plan")).toHaveText("Free")
|
||||
await expect(settingToggle(page, "Media storage")).not.toBeChecked()
|
||||
await expect(settingToggle(page, "LiveKit support")).not.toBeChecked()
|
||||
|
|
@ -987,8 +804,7 @@ test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => {
|
|||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
// Where the space ends up once the domain verifies, so the client has somewhere real to follow
|
||||
// it to.
|
||||
// Where the space ends up once the domain verifies.
|
||||
other.room("general", {name: "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 page = await as(users.alice, spacePath(url) + "/admin", {
|
||||
hosting: {plans: PLANS, relays: [hostedRelay()]},
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
await expect(page.getByText("Not configured")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Add domain"}).click()
|
||||
|
||||
const domainForm = dialog(page, "Custom domain")
|
||||
|
|
@ -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 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})
|
||||
|
||||
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: {
|
||||
plans: PLANS,
|
||||
relays: [hostedRelay({plan_id: "basic"})],
|
||||
// Declared oldest first, which is not the order payment history reads in. Invoice's
|
||||
// paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is
|
||||
// how the hosting API spells "hasn't happened yet".
|
||||
// Declared oldest first, which is not the order payment history reads in. null is "not yet".
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
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 menuItem(page, "Deactivate").click()
|
||||
|
||||
const deactivate = dialog(page, "Deactivate relay?")
|
||||
|
||||
await expect(deactivate.getByText(/All client connections are dropped/)).toBeVisible()
|
||||
|
||||
await deactivate.getByRole("button", {name: "Confirm"}).click()
|
||||
await dialog(page, "Deactivate relay?").getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
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")
|
||||
|
||||
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"})
|
||||
|
||||
|
|
@ -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 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(await period(openPeriod))
|
||||
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}) => {
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
||||
// The space menu button is labeled with the space's host, the way `openSpaceMenu` relies on for
|
||||
// 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.
|
||||
// delegated.test grants every member one management method, so `supportedmethods` differs by user.
|
||||
const openDelegatedMenu = (page: Page) =>
|
||||
page.getByRole("button", {name: /delegated\.test/}).click()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import * as nip19 from "nostr-tools/nip19"
|
||||
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 {Article, Comment, Thread} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {
|
||||
DEFAULT_BLOSSOM_ORIGIN,
|
||||
GIF,
|
||||
emojiButton,
|
||||
expect,
|
||||
getPublishedEvents,
|
||||
mockBlossom,
|
||||
modalForm,
|
||||
noteEditor,
|
||||
pageBar,
|
||||
|
|
@ -24,15 +21,11 @@ import {
|
|||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
||||
|
||||
const PARTY = "🎉"
|
||||
|
||||
// The comment and thread-reply composers are the only forms on their pages carrying a rich text
|
||||
// editor.
|
||||
// The comment and thread-reply composers are the only forms on their pages with a rich text editor.
|
||||
const composerForm = (page: Page) =>
|
||||
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 thread written from inside a room is started.
|
||||
// A room composer's join is the upload button and then the compose menu.
|
||||
const openComposeMenu = (page: Page) =>
|
||||
page
|
||||
.locator("form")
|
||||
|
|
@ -42,107 +35,57 @@ const openComposeMenu = (page: Page) =>
|
|||
.nth(1)
|
||||
.click()
|
||||
|
||||
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
|
||||
|
||||
// One reaction, recorded as the reader's own, and taken back off again.
|
||||
// One reaction, added and taken back off again.
|
||||
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).toHaveClass(/button-primary/)
|
||||
|
||||
await pill.click()
|
||||
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
|
||||
// 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.
|
||||
// The card is a div carrying an overlay link, so it is found by its component rather than a role.
|
||||
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
|
||||
// the specs to name; the marker sits on the comment's own row, not on its replies.
|
||||
// A comment is a flat block rather than a card, and the marker sits on its own row, not its replies.
|
||||
const comment = (page: Page, text: string) =>
|
||||
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
|
||||
// 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.
|
||||
// Clicked near its top-left: a card whose footer wraps puts an interactive row under the centre.
|
||||
const openArticle = async (page: Page, title: string) => {
|
||||
await articleCards(page)
|
||||
.filter({hasText: title})
|
||||
.getByRole("link", {name: title, exact: true})
|
||||
.click({position: {x: 20, y: 20}})
|
||||
|
||||
// The card's own action bar carries a data-component the article page carries too, and the list
|
||||
// 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.
|
||||
// The card's own action bar carries the same data-component, and the list is still on screen.
|
||||
await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible()
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
// An older article, so "at the top of the list" is a statement about order.
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("old-news")
|
||||
.setTitle("Old News")
|
||||
.setSummary("Something published a while back.")
|
||||
.setPublishedAt(at(3, HOUR))
|
||||
.setContent("Yesterday's headlines.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, `${spacePath(url)}/articles`)
|
||||
|
||||
await expect(articleCards(page)).toHaveCount(1)
|
||||
|
||||
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||
|
||||
const publish = pageBar(page).getByRole("button", {name: "Publish"})
|
||||
const title = page.getByPlaceholder("Title", {exact: true})
|
||||
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 publish.click()
|
||||
await expect(page.getByRole("alert")).toContainText("Please write something for your article.")
|
||||
|
||||
await body.pressSequentially("Only the beginning.")
|
||||
|
||||
// Neither refusal put anything on the wire.
|
||||
expect(getPublishedEvents(page.context(), LONG_FORM)).toEqual([])
|
||||
|
||||
await page.goBack()
|
||||
await expect(articleCards(page)).toHaveCount(1)
|
||||
|
||||
// Reopening picks up where the composer was closed rather than starting over.
|
||||
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 expect(articleCards(page)).toHaveCount(2)
|
||||
await expect(articleCards(page).first()).toContainText("Signals in the Noise")
|
||||
await expect(articleCards(page).filter({hasText: "Signals in the Noise"})).toBeVisible()
|
||||
|
||||
// A published article leaves the composer empty for the next one.
|
||||
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 = [
|
||||
"## Why gardens matter",
|
||||
"",
|
||||
"Gardens are **worth the trouble**.",
|
||||
"",
|
||||
"- Soil",
|
||||
"- Water",
|
||||
"- Sunlight",
|
||||
"Gardens are worth the trouble.",
|
||||
"",
|
||||
`Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`,
|
||||
].join("\n")
|
||||
|
|
@ -239,61 +177,22 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
|
|||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.carol,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("winter-reading")
|
||||
.setTitle("Winter Reading")
|
||||
.setSummary("Six books for the dark months.")
|
||||
.setTopics([
|
||||
"books",
|
||||
"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 page = await as(users.bob, `${spacePath(url)}/articles`)
|
||||
|
||||
const garden = articleCards(page).filter({hasText: "Tending the Garden"})
|
||||
|
||||
await expect(articleCards(page)).toHaveCount(4)
|
||||
|
||||
// Author, published date and a preview, all on the card itself.
|
||||
await expect(garden).toContainText("Written by")
|
||||
await expect(articleCards(page)).toHaveCount(3)
|
||||
await expect(garden).toContainText("Alice Anderson")
|
||||
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
|
||||
.locator("section")
|
||||
.filter({has: page.getByRole("heading", {name: "Authors"})})
|
||||
const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})})
|
||||
|
||||
await expect(authors.getByRole("button", {name: /Alice Anderson/})).toBeVisible()
|
||||
await expect(authors.getByRole("button", {name: /Carol Chen/})).toBeVisible()
|
||||
|
||||
await topics.getByRole("button", {name: /^#gardening/}).click()
|
||||
await expect(articleCards(page)).toHaveCount(2)
|
||||
await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible()
|
||||
|
|
@ -305,17 +204,11 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
|
|||
|
||||
await openArticle(page, "Tending the Garden")
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
|
||||
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
|
||||
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
|
||||
// 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")
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
@ -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 composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
// The comment renders from the optimistic write, but the composer holds what was typed until the
|
||||
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
|
||||
// The composer holds what was typed until the relay confirms it, so the page carries this text twice.
|
||||
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
|
||||
// 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.
|
||||
// A comment on a room event is a room event, so without the room tag the relay sees no group.
|
||||
await expect
|
||||
.poll(() =>
|
||||
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)
|
||||
|
||||
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
||||
await openArticle(alice, "Tending the Garden")
|
||||
|
||||
await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible()
|
||||
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||
|
||||
// A reply is nested inside the comment it answers; a top level comment is not.
|
||||
const nested = alice.locator('[data-component="CommentReplies"]')
|
||||
|
||||
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
|
||||
|
||||
await alice.getByRole("button", {name: "Add a comment"}).click()
|
||||
|
||||
const composer = composerForm(alice)
|
||||
|
||||
await 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}) => {
|
||||
let thread!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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.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(
|
||||
user.alice,
|
||||
() =>
|
||||
|
|
@ -467,39 +324,6 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
|
|||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
const topic = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "general")
|
||||
.setTitle("Bed rotation")
|
||||
.setContent("How often do you move things around?")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent("Every other season, usually.")
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "A short note from the garden.", created_at: at(2, HOUR)}),
|
||||
)
|
||||
|
||||
thread = topic
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
|
@ -508,7 +332,7 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
|
|||
await openArticle(page, "Tending the Garden")
|
||||
|
||||
// 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")
|
||||
|
||||
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 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}) => {
|
||||
|
|
@ -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()
|
||||
|
||||
// The room hears about the article without alice posting it a second time. Its copy is published
|
||||
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
|
||||
// takes it with it.
|
||||
// The room's copy is published after the composer has moved on, and a page that unloads takes it.
|
||||
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
|
||||
|
||||
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"})
|
||||
|
||||
await expect(card).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}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.join(user.bob, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
// An older topic with replies, so the board's reply count and last-post time are statements
|
||||
// about the thread rather than about an empty row.
|
||||
const topic = space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "lounge")
|
||||
.setTitle("Chair procurement")
|
||||
.setContent("The old ones are done for.")
|
||||
.renderTemplate(),
|
||||
at(5, HOUR),
|
||||
)
|
||||
|
||||
const replies = ["Mesh, obviously.", "Anything but mesh."]
|
||||
|
||||
replies.forEach((content, index) => {
|
||||
space.event(
|
||||
index === 0 ? user.alice : user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent(content)
|
||||
.renderTemplate(),
|
||||
at(4, HOUR) + index * 60,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const {at} = scenario
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "lounge"))
|
||||
|
||||
|
|
@ -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`)
|
||||
|
||||
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
|
||||
const chairs = lounge.getByRole("row").filter({hasText: "Chair procurement"})
|
||||
|
||||
await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible()
|
||||
|
||||
// Topic, author, reply count and last post, in that order.
|
||||
await expect(chairs.getByRole("cell").nth(0)).toContainText("Chair procurement")
|
||||
await expect(chairs.getByRole("cell").nth(1)).toContainText("Bob Barker")
|
||||
await expect(chairs.getByRole("cell").nth(2)).toHaveText("2")
|
||||
await expect(chairs.getByRole("cell").nth(3)).toContainText(
|
||||
await shortDate(page, at(4, HOUR) + 60),
|
||||
)
|
||||
|
||||
const general = page
|
||||
.locator("section")
|
||||
.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
|
||||
// than from a picker.
|
||||
// Each board creates its own threads, so the room comes from the button that was clicked.
|
||||
await general.getByRole("button", {name: "Create", exact: true}).click()
|
||||
|
||||
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(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}) => {
|
||||
|
|
@ -724,22 +457,18 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
|
|||
at(3, HOUR),
|
||||
)
|
||||
|
||||
// Twenty replies is exactly the window a thread opens with, and the last of them is alice's,
|
||||
// so her OP badge has to survive bob's own reply pushing the oldest one out of it.
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
space.event(
|
||||
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent(`Reply ${String(i).padStart(2, "0")}`)
|
||||
.renderTemplate(),
|
||||
at(3, HOUR) + i * 60,
|
||||
)
|
||||
}
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent("Every other season.")
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
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 bob = await as(users.bob, threadPath)
|
||||
|
||||
const openingPost = bob.locator(`[data-event="${thread.id}"]`)
|
||||
|
||||
await expect(openingPost).toBeVisible()
|
||||
await expect(openingPost.getByText("OP", {exact: true})).toBeVisible()
|
||||
await expect(bob.getByText("20 replies")).toBeVisible()
|
||||
|
||||
// The opening post carries the title, and the summary bar replaces it once it scrolls away.
|
||||
await expect(openingPost).toContainText("Bed rotation")
|
||||
await expect(bob.locator(`[data-event="${thread.id}"]`)).toContainText("Bed rotation")
|
||||
|
||||
await bob.getByRole("button", {name: "Reply to thread"}).click()
|
||||
|
||||
const threadReply = composerForm(bob)
|
||||
|
||||
await expect(threadReply).toBeVisible()
|
||||
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
|
||||
|
||||
await noteEditor(threadReply).pressSequentially("Twice a year here.")
|
||||
await threadReply.getByRole("button", {name: "Post Reply"}).click()
|
||||
|
||||
await expect(bob.getByText("21 replies")).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
|
||||
.poll(() =>
|
||||
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
|
||||
)
|
||||
.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 bobsPost = carol.locator("article").filter({hasText: "Every other season."})
|
||||
|
||||
await carol.getByRole("button", {name: "Show earlier replies"}).click()
|
||||
|
||||
const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"})
|
||||
|
||||
await expect(bobsFirstPost).toBeVisible()
|
||||
await bobsFirstPost.getByRole("button", {name: "Reply", exact: true}).click()
|
||||
await bobsPost.getByRole("button", {name: "Reply", exact: true}).click()
|
||||
|
||||
const postReply = composerForm(carol)
|
||||
const replyingTo = postReply.locator(".border-l-2")
|
||||
|
||||
await expect(replyingTo).toContainText("Replying to @Bob Barker")
|
||||
await expect(replyingTo).toContainText("Reply 01")
|
||||
await expect(postReply).toContainText("Replying to @Bob Barker")
|
||||
|
||||
await replyingTo.getByRole("button").last().click()
|
||||
|
||||
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
|
||||
|
||||
await noteEditor(postReply).pressSequentially("Answering the thread instead.")
|
||||
await noteEditor(postReply).pressSequentially("Every season here.")
|
||||
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("Answering the thread instead.")).toBeVisible()
|
||||
await expect(carol.getByText("Every season here.")).toBeVisible()
|
||||
})
|
||||
|
||||
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"})
|
||||
|
||||
// Every assertion below is about which of the replies are in the window, so wait until they
|
||||
// have all arrived.
|
||||
// Every assertion below is about which of the replies are in the window, so wait for all of them.
|
||||
await expect(bob.getByText("41 replies")).toBeVisible()
|
||||
|
||||
// The thread opens on its newest twenty replies, with the opening post above them.
|
||||
await expect(bob.locator(`[data-event="${thread.id}"]`)).toBeVisible()
|
||||
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
|
||||
// The thread opens on its newest twenty replies.
|
||||
await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible()
|
||||
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.
|
||||
await showEarlier.click()
|
||||
|
||||
await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible()
|
||||
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()
|
||||
|
||||
|
|
@ -909,12 +590,11 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
|
|||
const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`)
|
||||
|
||||
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()))
|
||||
|
||||
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.
|
||||
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()
|
||||
|
||||
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.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`)
|
||||
|
||||
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
|
||||
const row = lounge.getByRole("row").filter({hasText: "Deploy failures"})
|
||||
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.click()
|
||||
await lounge.getByRole("row").filter({hasText: "Deploy failures"}).click()
|
||||
|
||||
await expect(page.locator("article").filter({hasText: "Deploy failures"})).toBeVisible()
|
||||
await expect(page.getByText("the deploy broke again")).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ test("authenticates over nip-42 before a members-only relay serves anything", as
|
|||
pubkey: users.alice.pubkey,
|
||||
})
|
||||
|
||||
// The whole point of intercepting the transport rather than swapping in an adapter: until the
|
||||
// client proves who it is, the relay hands it nothing at all.
|
||||
// Until the client proves who it is, the relay hands it nothing at all.
|
||||
expect(firstEventAt).toBeGreaterThan(authenticatedAt)
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,42 +24,15 @@ import {
|
|||
|
||||
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 chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")
|
||||
|
||||
// Both things the composer puts above itself — the message being replied to and the editing
|
||||
// indicator — are the same bordered strip, each with a single button in it: its X.
|
||||
// The reply preview and the editing indicator are the same bordered strip, each with its own X.
|
||||
const banner = (page: Page, text: string) =>
|
||||
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) =>
|
||||
editor.evaluate(
|
||||
(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.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
|
||||
// Someone whose name matches the same term but who belongs to a different space, so the
|
||||
// dropdown has a non-member to rank below this space's own members.
|
||||
// Someone matching the same term from a different space, to rank below this space's own members.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.alice, "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 other = scenario.space("other")
|
||||
|
||||
// Alice arrives through the other space so that the outsider's profile is in her client before
|
||||
// she composes: a profile reaches her by being rendered, and his never renders in the space she
|
||||
// is about to type in.
|
||||
// A profile reaches her by being rendered, and his never renders in the space she is typing in.
|
||||
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".
|
||||
|
|
@ -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}),
|
||||
).toBeVisible()
|
||||
|
||||
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants
|
||||
// report "space" and "other".
|
||||
// A space's nav item is labeled with the name its nip-11 document reports.
|
||||
await page.locator('.primary-nav [data-tip="space"]').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.
|
||||
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||||
|
||||
await editor.pressSequentially("bin")
|
||||
|
||||
await expect(suggestions(page)).toContainText(["Bobbin Amaranth"])
|
||||
|
||||
// Back to "@bob", where both of them are on offer again.
|
||||
await editor.press("Backspace")
|
||||
await editor.press("Backspace")
|
||||
await editor.press("Backspace")
|
||||
|
||||
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||||
|
||||
await suggestions(page).filter({hasText: "Bob Roberts"}).click()
|
||||
|
||||
const mention = editor.locator(".tiptap-object")
|
||||
|
|
@ -201,31 +159,23 @@ test("US-057 attach and send an image", async ({seed, as}) => {
|
|||
|
||||
await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif"))
|
||||
|
||||
await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible()
|
||||
await expect(sendButton(alice)).toBeDisabled()
|
||||
|
||||
blossom.release()
|
||||
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
await expect(composer(alice)).toContainText("photo.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
await expect(timeline(alice).locator(`img[src^="${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 expect(composer(alice)).toContainText("pasted.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
|
||||
// The same thing in a conversation. Its composer stays disabled until the recipient's messaging
|
||||
// relays have been read, which is what waiting on the composer waits out.
|
||||
// Its composer stays disabled until the recipient's messaging relays have been read.
|
||||
await alice.goto(chatPath(users.bob.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")
|
||||
|
||||
// The file node appears the moment it is attached, which is also the moment `uploading` goes
|
||||
// true — and submit returns without a word while it is. Encryption makes that window wide enough
|
||||
// to press enter into, so wait the upload out rather than losing the message to it.
|
||||
// Submit returns without a word while `uploading` is true, and encryption makes that window wide.
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and
|
||||
// decrypts it into a blob url rather than pointing an <img> at the server.
|
||||
// A conversation's image is uploaded encrypted, so the recipient decrypts it into a blob url.
|
||||
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible()
|
||||
|
||||
// Back to the room on a fresh page, so no toast the conversation raised is still standing and the
|
||||
// one asserted below can only be the rejected upload's own.
|
||||
// Back to the room on a fresh page, so no toast the conversation raised is still standing.
|
||||
await alice.goto(path)
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
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.
|
||||
blossom.refuse("text files are not allowed here")
|
||||
|
||||
await chooseFile(alice, roomUploadButton(alice), {
|
||||
name: "notes-again.txt",
|
||||
name: "notes.txt",
|
||||
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")
|
||||
|
|
@ -285,8 +221,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
|
|||
space.join(user.bob, "general")
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
|
||||
// Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask
|
||||
// her to enable chat; bob's is what enables the conversation's composer.
|
||||
// Her kind-10050 lets the Messages nav navigate; his enables the conversation's composer.
|
||||
for (const person of [user.alice, user.bob]) {
|
||||
space.profile(person, {name: person.name})
|
||||
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
|
||||
// whether or not they were kept.
|
||||
// Drafts live in memory, so every move here is an in-app navigation.
|
||||
const page = await as(users.alice, chatPath(users.bob.pubkey))
|
||||
const editor = composer(page)
|
||||
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")
|
||||
|
||||
// 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.
|
||||
// The composer is remounted around a restored draft, so put the caret in it before sending.
|
||||
await editor.click()
|
||||
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")
|
||||
|
||||
// Bob's message offers zap, emoji, reply and the menu; alice's own also offers the edit.
|
||||
await expect(messageActions(page, "morning all")).toHaveCount(4)
|
||||
await expect(messageActions(page, "my first message")).toHaveCount(5)
|
||||
|
||||
await messageActions(page, "morning all").nth(2).click()
|
||||
|
||||
await expect(replyBanner).toContainText("morning all")
|
||||
|
|
@ -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(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.
|
||||
|
|
@ -449,8 +373,7 @@ test("US-125 dictate a message", async ({seed, as}) => {
|
|||
const alice = await as(users.alice, roomPath(url, "general"))
|
||||
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
|
||||
// prompt reading a message out loud uses.
|
||||
// Recording asks for nothing. Asking for a transcript with no key saved asks for one.
|
||||
const action = await record(alice)
|
||||
|
||||
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")
|
||||
|
||||
// OpenRouter picks its decoder off the extension, so what the recorder produced has to reach it
|
||||
// under a name that names the format.
|
||||
// OpenRouter picks its decoder off the extension, so the upload has to name the format.
|
||||
expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)])
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
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
|
||||
// left while the request is still out, and the transcript waits for whichever composer is next.
|
||||
// A transcription outlives the composer that asked for it, and waits for whichever comes next.
|
||||
transcription.hold()
|
||||
|
||||
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
|
||||
// that started one, so reloading the page is losing it rather than leaving it.
|
||||
// A dictation is held by the app rather than by the composer, so reloading the page is losing it.
|
||||
await roomLink(alice, "Lounge").click()
|
||||
|
||||
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})
|
||||
|
||||
// Leaving the question unanswered throws the recording away, so nothing is uploaded and the
|
||||
// composer is where it was.
|
||||
// Leaving the question unanswered throws the recording away.
|
||||
await (await record(alice)).getByRole("button", {name: "Discard"}).click()
|
||||
|
||||
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")
|
||||
|
||||
// The imeta on the message says the upload is audio, which is what gives it a player rather
|
||||
// than a link.
|
||||
// The imeta on the message says the upload is audio, which gives it a player rather than a link.
|
||||
await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,32 +1,18 @@
|
|||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||
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 {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.
|
||||
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.
|
||||
// @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows.
|
||||
const INVOICE =
|
||||
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" +
|
||||
"ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" +
|
||||
"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}) => {
|
||||
let flagged!: Seeded
|
||||
let flagged!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.alice, "general", "no spoilers please", at(45, MINUTE))
|
||||
|
||||
flagged = space.event(
|
||||
user.bob,
|
||||
|
|
@ -52,8 +37,6 @@ test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
|
|||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
// Scoped to bob's own message, so "in place of his text" is a claim about this message rather
|
||||
// than about the room as a whole.
|
||||
const message = page.locator(`[data-event="${flagged.id}"]`)
|
||||
const warning = message.getByText('flagged by the author as "spoilers"')
|
||||
|
||||
|
|
@ -110,14 +93,13 @@ test("US-061 expand a long post", async ({seed, as}) => {
|
|||
|
||||
await expect(page.getByText(paragraphs[11])).toBeVisible()
|
||||
|
||||
// The whole card is a link into the article, so expanding in place means the click never
|
||||
// reached it.
|
||||
// The whole card is a link into the article, so expanding in place means the click never reached.
|
||||
expect(page.url()).toBe(before)
|
||||
})
|
||||
|
||||
test("US-062 see images and video inline", async ({seed, as}) => {
|
||||
let picture!: Seeded
|
||||
let clip!: Seeded
|
||||
let picture!: SeededEvent
|
||||
let clip!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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 inlineImage = pictureMessage.locator('img[src="https://images.test/sunset.png"]')
|
||||
|
||||
await expect(inlineImage).toBeVisible()
|
||||
await expect(pictureMessage.getByText("images.test/sunset.png")).toHaveCount(0)
|
||||
|
||||
await inlineImage.click()
|
||||
|
||||
// The lightbox is a fullscreen modal holding nothing but the image.
|
||||
const preview = page.getByRole("dialog", {name: "Content preview"})
|
||||
const lightbox = preview.locator("img")
|
||||
|
||||
await expect(preview).toBeVisible()
|
||||
await expect(lightbox).toBeVisible()
|
||||
await expect(preview.locator("img")).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await expect(page.locator(".dialog")).toHaveCount(0)
|
||||
await expect(inlineImage).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)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await expect(clipMessage.locator("video")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-063 preview a shared link", async ({seed, as}) => {
|
||||
const announcement = "https://example.test/announcement"
|
||||
const unreadable = "https://example.test/unreadable"
|
||||
|
||||
let standalone!: Seeded
|
||||
let broken!: Seeded
|
||||
let sentence!: Seeded
|
||||
let standalone!: SeededEvent
|
||||
let broken!: SeededEvent
|
||||
let sentence!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
|
@ -196,26 +158,16 @@ test("US-063 preview a shared link", async ({seed, as}) => {
|
|||
|
||||
const {url} = scenario.space("space")
|
||||
|
||||
// The room is opened by hand below rather than by as(): a preview is fetched once per url and
|
||||
// memoized for the life of the page, so the backend has to be standing before the first render.
|
||||
// A preview is fetched once per url and memoized for the life of the page.
|
||||
const page = await as(users.alice, "/")
|
||||
|
||||
let servePreview = () => {}
|
||||
|
||||
const previewReleased = new Promise<void>(resolve => {
|
||||
servePreview = resolve
|
||||
})
|
||||
|
||||
// Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one
|
||||
// preview open makes the loading state a fact rather than a race.
|
||||
// Registered after as(), so it answers ahead of the harness's own dufflepud.
|
||||
await page.context().route(
|
||||
requestUrl => requestUrl.pathname === "/link/preview",
|
||||
async route => {
|
||||
const {url: target}: {url: string} = route.request().postDataJSON()
|
||||
|
||||
if (target === announcement) {
|
||||
await previewReleased
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
title: "Flotilla ships v1",
|
||||
|
|
@ -235,26 +187,17 @@ test("US-063 preview a shared link", async ({seed, as}) => {
|
|||
const card = page.locator(`[data-event="${standalone.id}"]`)
|
||||
const failed = page.locator(`[data-event="${broken.id}"]`)
|
||||
const inline = page.locator(`[data-event="${sentence.id}"]`)
|
||||
const previewImage = 'img[src="https://images.test/preview.png"]'
|
||||
|
||||
await expect(card.locator(".spinner")).toBeVisible()
|
||||
await expect(failed.getByRole("link", {name: "example.test/unreadable"})).toBeVisible()
|
||||
|
||||
servePreview()
|
||||
|
||||
await expect(card.getByText("Flotilla ships v1")).toBeVisible()
|
||||
await expect(card.getByText("Everything new in this release.")).toBeVisible()
|
||||
await expect(card.locator(previewImage)).toBeVisible()
|
||||
await expect(failed.getByRole("link", {name: "example.test/unreadable"})).toBeVisible()
|
||||
|
||||
// The same url mid-sentence, now that the preview it would have shown is known to resolve: a
|
||||
// compact link, and none of the card.
|
||||
// The same url mid-sentence, now that the preview it would have shown is known to resolve.
|
||||
await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
|
||||
await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0)
|
||||
await expect(inline.locator(previewImage)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-064 follow a link to another space", async ({seed, as}) => {
|
||||
let reference!: Seeded
|
||||
let reference!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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 page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
const message = page.locator(`[data-event="${reference.id}"]`)
|
||||
const spaceLink = message.getByRole("link", {name: "other.test"})
|
||||
|
||||
await expect(spaceLink).toBeVisible()
|
||||
await expect(message.getByText(other.url)).toHaveCount(0)
|
||||
await expect(spaceLink).toHaveAttribute("href", spacePath(other.url))
|
||||
await expect(spaceLink).not.toHaveAttribute("target", "_blank")
|
||||
|
||||
await spaceLink.click()
|
||||
await page
|
||||
.locator(`[data-event="${reference.id}"]`)
|
||||
.getByRole("link", {name: "other.test"})
|
||||
.click()
|
||||
|
||||
await page.waitForURL(next => next.pathname.startsWith(spacePath(other.url)))
|
||||
|
||||
await expect(
|
||||
page.locator(".secondary-nav").getByRole("link", {name: "Other Lounge"}),
|
||||
).toBeVisible()
|
||||
|
||||
expect(page.context().pages()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("US-065 see quoted and embedded content", async ({seed, as}) => {
|
||||
let original!: Seeded
|
||||
let reply!: Seeded
|
||||
let thread!: Seeded
|
||||
let threadQuote!: Seeded
|
||||
let unresolvable!: Seeded
|
||||
let original!: SeededEvent
|
||||
let reply!: SeededEvent
|
||||
let thread!: SeededEvent
|
||||
let threadQuote!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anders"})
|
||||
space.profile(user.bob, {name: "Bob Boberton"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
const point = space.message(user.alice, "general", "the original point", at(6, HOUR))
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
// Carol says something in the room too, so her profile is loaded by the time her name has to
|
||||
// render on a card quoting her thread.
|
||||
space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE))
|
||||
|
||||
const topic = space.event(
|
||||
user.carol,
|
||||
() =>
|
||||
|
|
@ -344,31 +270,15 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
|
|||
thread = topic
|
||||
reply = space.reply(user.bob, point, "totally agree with this", at(20, MINUTE))
|
||||
threadQuote = space.reply(user.bob, topic, "worth a read", at(15, MINUTE))
|
||||
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 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 quoteStrip = replyMessage.locator(".border-l-2")
|
||||
|
||||
await expect(quoteStrip).toContainText("the original point")
|
||||
await expect(replyMessage).toContainText("totally agree with this")
|
||||
|
||||
const originalMessage = page.locator(`[data-event="${original.id}"]`)
|
||||
|
||||
|
|
@ -379,34 +289,114 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
|
|||
|
||||
await expect(originalMessage).toBeInViewport()
|
||||
|
||||
// The quoted thread's card, once it has loaded: the profile circle beside the message carries
|
||||
// the same border classes, so the title is what says which of them is meant.
|
||||
const quotedThread = threadMessage.locator(".border.border-solid").filter({
|
||||
hasText: "Roadmap for Q3",
|
||||
})
|
||||
|
||||
await expect(quotedThread).toBeVisible()
|
||||
await expect(quotedThread).toContainText("Carol Chen")
|
||||
await expect(quotedThread).toContainText("Here is what we are planning for the next quarter.")
|
||||
|
||||
await quotedThread.getByText("Roadmap for Q3").click()
|
||||
await page.locator(`[data-event="${threadQuote.id}"]`).getByText("Roadmap for Q3").click()
|
||||
|
||||
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}) => {
|
||||
const mention = `nostr:${npubEncode(users.bob.pubkey)}`
|
||||
const content = [
|
||||
"Shipping #nostr today :partyparrot:",
|
||||
`Run \`npm install\` first, then ping ${mention} or email team@example.test`,
|
||||
"Shipping today :partyparrot:",
|
||||
`Run \`npm install\` first, then ping ${mention}`,
|
||||
"```",
|
||||
"const answer = 42",
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
let tokens!: Seeded
|
||||
let tokens!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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.
|
||||
space.message(user.bob, "general", "morning all", at(50, MINUTE))
|
||||
|
||||
// The shortcode's image comes off the message's own emoji tag, which is what a client writes
|
||||
// when someone picks a custom emoji.
|
||||
// The shortcode's image comes off the message's own emoji tag.
|
||||
tokens = space.event(
|
||||
user.carol,
|
||||
makeEvent(MESSAGE, {
|
||||
|
|
@ -441,37 +430,17 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
|
|||
|
||||
const message = page.locator(`[data-event="${tokens.id}"]`)
|
||||
|
||||
await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/)
|
||||
await expect(message.getByAltText(":partyparrot:")).toBeVisible()
|
||||
// An img contributes no text, so this is the "instead of the raw text" half of the story: with
|
||||
// no emoji tag to resolve, the shortcode is rendered verbatim.
|
||||
await expect(message.getByText(":partyparrot:")).toHaveCount(0)
|
||||
|
||||
const inlineCode = message.locator("code").filter({hasText: "npm install"})
|
||||
const blockCode = message.locator("code").filter({hasText: "const answer = 42"})
|
||||
|
||||
await expect(inlineCode).toBeVisible()
|
||||
await expect(inlineCode).not.toHaveClass(/(^|\s)block(\s|$)/)
|
||||
await expect(blockCode).toBeVisible()
|
||||
await expect(blockCode).toHaveClass(/(^|\s)block(\s|$)/)
|
||||
|
||||
await expect(message.getByRole("link", {name: "team@example.test"})).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:team@example.test",
|
||||
)
|
||||
await expect(message.locator("code").filter({hasText: "npm install"})).toBeVisible()
|
||||
await expect(message.locator("code").filter({hasText: "const answer = 42"})).toBeVisible()
|
||||
|
||||
await message.getByRole("button", {name: "@Bob Boberton"}).click()
|
||||
|
||||
// Dialog wraps its content in a second element of its own, so the inner one is the modal.
|
||||
const profile = page.locator(".dialog").last()
|
||||
|
||||
await expect(profile.getByText("Bob Boberton", {exact: true})).toBeVisible()
|
||||
await expect(profile.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-067 copy a shared invoice or token", async ({seed, as}) => {
|
||||
let invoice!: Seeded
|
||||
let token!: Seeded
|
||||
let invoice!: SeededEvent
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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")
|
||||
|
||||
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")
|
||||
|
|
@ -489,34 +457,11 @@ test("US-067 copy a shared invoice or token", async ({seed, as}) => {
|
|||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
const toast = page.getByRole("alert")
|
||||
const invoiceChip = page
|
||||
await page
|
||||
.locator(`[data-event="${invoice.id}"]`)
|
||||
.getByRole("button", {name: INVOICE.slice(0, 16)})
|
||||
.click()
|
||||
|
||||
await expect(invoiceChip).toBeVisible()
|
||||
await expect(page.getByText(INVOICE)).toHaveCount(0)
|
||||
|
||||
await invoiceChip.click()
|
||||
|
||||
await expect(toast).toContainText("Copied to clipboard!")
|
||||
await expect(page.getByRole("alert")).toBeVisible()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE)
|
||||
|
||||
// Both copies raise the same toast, so this one is dismissed rather than waited out — otherwise
|
||||
// the second assertion would pass on the first toast.
|
||||
await toast.getByRole("button").click()
|
||||
|
||||
await expect(toast).toHaveCount(0)
|
||||
|
||||
const tokenChip = page
|
||||
.locator(`[data-event="${token.id}"]`)
|
||||
.getByRole("button", {name: CASHU.slice(0, 16)})
|
||||
|
||||
await expect(tokenChip).toBeVisible()
|
||||
await expect(page.getByText(CASHU)).toHaveCount(0)
|
||||
|
||||
await tokenChip.click()
|
||||
|
||||
await expect(toast).toContainText("Copied to clipboard!")
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(CASHU)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {HOUR} from "@welshman/lib"
|
||||
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 {
|
||||
DEFAULT_BLOSSOM_ORIGIN,
|
||||
|
|
@ -34,15 +34,13 @@ import type {SeededSpace, TestUser} from "../harness"
|
|||
const articlePath = (url: string, address: string) =>
|
||||
`${spacePath(url)}/articles/${encodeURIComponent(address)}`
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, so a person here is
|
||||
// a membership, a profile and a kind-10002.
|
||||
// Outbox routing resolves a person through their relay list, so a person here has a kind-10002.
|
||||
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
|
||||
space.profile(user, {name})
|
||||
space.relayList(user)
|
||||
}
|
||||
|
||||
// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its
|
||||
// content mounted after it hides, so the visible card there is the popover that was just opened.
|
||||
// A tippy is appended to the layout's own target and keeps its content mounted after it hides.
|
||||
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.
|
||||
|
|
@ -50,7 +48,7 @@ const commentCard = (page: Page, text: string) =>
|
|||
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.
|
||||
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.
|
||||
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()
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
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
|
||||
// caret back at the start partway through the sentence.
|
||||
// The editor takes focus itself once it has mounted, and typing before that puts the caret back.
|
||||
await expect(noteEditor(form)).toBeFocused()
|
||||
await noteEditor(form).pressSequentially(body)
|
||||
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
|
||||
// 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.
|
||||
// The slider is initialised from the settings store on mount, so a fresh page says the client took it.
|
||||
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 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 bob = await as(users.bob, path)
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
await expect(timeline(bob).getByText("morning all")).toBeVisible()
|
||||
|
||||
// The delay defaults to zero, so this one leaves as soon as it is written and says nothing.
|
||||
await send(alice, "no delay here")
|
||||
|
||||
await expect(message(alice, "no delay here")).toBeVisible()
|
||||
await expect(message(bob, "no delay here")).toBeVisible()
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
await setSendDelay(alice, 5)
|
||||
await alice.goto(path)
|
||||
|
||||
|
|
@ -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(toast(alice)).toHaveCount(0)
|
||||
|
||||
// Sent after the cancelled one and delayed by as long, so bob having this means the window the
|
||||
// cancelled one would have left in has been and gone.
|
||||
// Sent after the cancelled one and delayed by as long, so its window has been and gone.
|
||||
await send(alice, "still here")
|
||||
|
||||
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()
|
||||
|
||||
// That was her only message to him, so the conversation goes with it rather than staying in
|
||||
// the list with nothing left to name it by.
|
||||
// That was her only message to him, so the conversation goes with it.
|
||||
await expect(chatItems(alice)).toHaveCount(0)
|
||||
|
||||
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)
|
||||
// Bob's client says his messages go to both relays, but he is not a member of the second, so a
|
||||
// wrap addressed to him is stored by one and refused by the other.
|
||||
// Bob's client names both relays, but he is a member of one, so the other refuses a wrap for him.
|
||||
space.messagingRelayList(user.bob, [space.url, other.url])
|
||||
|
||||
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")
|
||||
|
||||
// A room the relay never created, which is where a link to a room that has since been deleted
|
||||
// lands. Everything else about the space still works, so only this one publish is refused.
|
||||
// A room the relay never created, which is where a link to a room that has since been deleted lands.
|
||||
const ghost = roomPath(url, "archive")
|
||||
|
||||
const alice = await as(users.alice, ghost)
|
||||
const bob = await as(users.bob, ghost)
|
||||
|
||||
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()
|
||||
|
||||
// The text is still hers to see, and the publish is finished and was refused, so there is
|
||||
// nothing left for bob to receive.
|
||||
await expect(message(alice, "anyone here?")).toContainText("anyone here?")
|
||||
await expect(message(bob, "anyone here?")).toHaveCount(0)
|
||||
|
||||
await failure.click()
|
||||
|
||||
await expect(detail(alice)).toContainText("Failed to send!")
|
||||
|
|
@ -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("other.test")
|
||||
await expect(detail(alice)).toContainText("restricted:")
|
||||
await expect(detail(alice).locator(".text-success")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("US-070 retry a failed relay", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
other.join(user.alice)
|
||||
seedPerson(space, user.alice, "Alice Anderson")
|
||||
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 ghost = roomPath(url, "archive")
|
||||
const alice = await as(users.alice, ghost)
|
||||
|
||||
// Opened now rather than once the room exists: seeding again below replaces the scenario, and a
|
||||
// page opened from that one carries no room list — which is the only thing telling authPolicy it
|
||||
// may answer a members-only relay's challenge, so bob would never get to read anything.
|
||||
// Seeding again replaces the scenario, and a page opened from that one carries no room list.
|
||||
const bob = await as(users.bob, roomPath(url, "general"))
|
||||
|
||||
// A delay is what makes the retry's own "Sending..." a state rather than an instant, so this
|
||||
// watches a retry the way the person who asked for it does.
|
||||
// A delay is what makes the retry's own "Sending..." a state rather than an instant.
|
||||
await setSendDelay(alice, 5)
|
||||
await alice.goto(ghost)
|
||||
|
||||
|
|
@ -285,18 +268,7 @@ test("US-070 retry a failed relay", async ({seed, as}) => {
|
|||
|
||||
await expect(failure).toBeVisible()
|
||||
|
||||
await failure.click()
|
||||
await detail(alice).getByRole("button", {name: "Retry"}).click()
|
||||
|
||||
await expect(toast(alice)).toContainText("Sending...")
|
||||
|
||||
// The room still does not exist, so this attempt is refused too: the toast goes without ever
|
||||
// having said the message was sent, and the message is still marked failed.
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
await expect(failure).toBeVisible()
|
||||
|
||||
// The admin creates the room the message was addressed to. Seeding again is the only way the
|
||||
// relay changes its mind about something it has already refused.
|
||||
// Seeding again is the only way the relay changes its mind about something it has already refused.
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("archive", {name: "Archive"})
|
||||
})
|
||||
|
|
@ -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("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)
|
||||
|
||||
// One attempt and two retries, every one of them to the relay that failed and to nothing else.
|
||||
expect(publishedTo(alice, sent.id)).toEqual([url, url, url])
|
||||
// The attempt and the retry, both to the relay that failed and to nothing else.
|
||||
expect(publishedTo(alice, sent.id)).toEqual([url, url])
|
||||
|
||||
await bob.goto(roomPath(url, "archive"))
|
||||
|
||||
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}) => {
|
||||
|
|
@ -335,14 +328,12 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
|
|||
const {url} = scenario.space("space")
|
||||
const quiet = scenario.space("other").url
|
||||
|
||||
// A space whose relay this browser will not open a socket to, which is what a post that sits
|
||||
// unconfirmed looks like from the inside: it is sent, and nothing comes back.
|
||||
// A space whose relay this browser will not open a socket to, so a post is sent and nothing returns.
|
||||
const alice = await as(users.alice, `${spacePath(url)}/articles`, {
|
||||
env: {VITE_BLOCKED_RELAYS: quiet},
|
||||
})
|
||||
|
||||
// A comment leaves after the send delay the way a chat message does, so the window in which its
|
||||
// Cancel link is live is hers to set.
|
||||
// A comment leaves after the send delay the way a chat message does.
|
||||
await setSendDelay(alice, 1)
|
||||
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.
|
||||
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.
|
||||
await alice.goto(`${spacePath(quiet)}/articles`)
|
||||
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 under the article is what says the
|
||||
// relay never answered.
|
||||
// Nothing was refused, so there is no toast; the action bar says the relay never answered.
|
||||
const stuck = articleActions(alice)
|
||||
|
||||
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.")
|
||||
|
||||
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 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),
|
||||
)
|
||||
|
||||
const comment = space.event(
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
|
|
@ -431,13 +412,6 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
|||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
// Somebody else's reaction, so the summary the pill is meant to replace has something in it.
|
||||
space.event(
|
||||
user.bob,
|
||||
() => space.kind(Reaction).writer().setEvent(comment.event).setContent("🎉").renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
|
@ -469,7 +443,6 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
|||
const article = articleActions(alice)
|
||||
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
|
||||
|
||||
await menuButton(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: "Confirm"}).click()
|
||||
|
||||
// But this page is the article's own view, so it stays and is marked deleted rather than
|
||||
// vanishing, and the "Deleted" pill stands in place of the actions it offered before.
|
||||
// This page is the article's own view, so it stays and is marked deleted rather than vanishing.
|
||||
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}) => {
|
||||
|
|
@ -502,8 +473,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
|
|||
|
||||
space.messagingRelayList(user.alice)
|
||||
space.messagingRelayList(user.bob)
|
||||
// Carol's client names a relay she does not belong to, so every part of a message to her is
|
||||
// stored by one of her two relays and refused by the other.
|
||||
// Carol's client names a relay she does not belong to, so one of the two refuses each part.
|
||||
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 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
|
||||
// upload is done — and a submit while it is still running is dropped on the floor.
|
||||
// The editor names the file the moment it is attached, and a submit while it uploads is dropped.
|
||||
await expect(composer(alice)).toContainText("harbour.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
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.
|
||||
await expect(toast(alice)).toContainText("Message sent!")
|
||||
await expect(toast(alice)).toHaveCount(1)
|
||||
await expect(bob.locator(".chat-bubble")).toHaveCount(2)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,16 +21,11 @@ import {
|
|||
roomPath,
|
||||
send,
|
||||
test,
|
||||
topDialog,
|
||||
users,
|
||||
} from "../harness"
|
||||
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// Everything about a person is resolved through their relay list — their profile, their messaging
|
||||
// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with
|
||||
// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list.
|
||||
// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a
|
||||
// kind-1059 by the member named in its p tag.
|
||||
// A person here is a membership, a profile and a relay list, since everything resolves through one.
|
||||
const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
|
||||
space.join(user, ...rooms)
|
||||
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.
|
||||
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
|
||||
// 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.
|
||||
// The reply preview is a bordered strip above the composer.
|
||||
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
|
||||
// once in these stories, and only the id says which bubble is which.
|
||||
// The same words are sent more than once in these stories, so only the id says which bubble is which.
|
||||
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
|
||||
|
||||
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"})
|
||||
|
||||
// The composer stays disabled until every recipient's messaging relays have been read, so waiting
|
||||
// on it is part of sending rather than a wait for a wait's sake.
|
||||
// The composer stays disabled until every recipient's messaging relays have been read.
|
||||
const sendDm = async (page: Page, content: string) => {
|
||||
await composerEnabled(page)
|
||||
await send(page, content)
|
||||
}
|
||||
|
||||
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the
|
||||
// pubkey is what identifies the row that comes back.
|
||||
// A suggestion carries the pubkey it selects as its label, so the pubkey identifies the row.
|
||||
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
|
||||
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) {
|
||||
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`)
|
||||
|
||||
// Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term
|
||||
// changing, so a term that arrives all at once is matched against whatever the profile search
|
||||
// held at that instant and never again.
|
||||
// The suggestion list is rebuilt off the term changing, so a term arriving at once is matched once.
|
||||
await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// A message's actions live behind a hover popover on a pointer device and behind a modal of named
|
||||
// buttons on a touch one, so a spec that wants to name them opens a touch context.
|
||||
// A message's actions are a hover popover on a pointer device and named buttons on a touch one.
|
||||
const openMessageMenu = (page: Page, id: string) =>
|
||||
message(page, id).locator(".chat-bubble").click()
|
||||
|
||||
// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the
|
||||
// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and
|
||||
// dateTimeFormatter in @welshman/lib.
|
||||
const dayLabel = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
|
||||
new Date(ts * 1000),
|
||||
),
|
||||
seconds,
|
||||
)
|
||||
|
||||
const stampLabel = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(
|
||||
new Date(ts * 1000),
|
||||
),
|
||||
seconds,
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Events reach indexeddb in three-second batches, so a spec that reloads has to read the cache first.
|
||||
const cachedContent = async (page: Page, pubkey: string, kind: number) =>
|
||||
(await readCachedEvents(page, pubkey))
|
||||
.filter(event => event.kind === kind)
|
||||
.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}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
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.bob)
|
||||
|
||||
// A conversation the three of them are already in. It puts bob's profile in her client before
|
||||
// she goes looking for him, without being the one-on-one she is about to start.
|
||||
// A conversation the three of them are already in, which puts bob's profile in her client.
|
||||
space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
// A group conversation's item in the list names only the first of its participants, so his name
|
||||
// rather than his npub in its header is the client saying it has his profile, which is what the
|
||||
// search she is about to type into is built from.
|
||||
// A group conversation's item in the list names only the first of its participants.
|
||||
await chatItems(page).filter({hasText: "welcome aboard"}).click()
|
||||
|
||||
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 expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(composer(page)).toBeVisible()
|
||||
await composerEnabled(page)
|
||||
|
||||
await sendDm(page, "hi bob")
|
||||
|
||||
await expect(bubble(page, "hi bob")).toBeVisible()
|
||||
|
||||
// The conversation she just started is the one at the top of her list, under his name
|
||||
await expect(chatItems(page).first()).toContainText("Bob Barnacle")
|
||||
await expect(chatItems(page).first()).toContainText("hi bob")
|
||||
|
||||
// His profile leads back into the same conversation
|
||||
await pageBar(page).getByRole("button", {name: "Bob Barnacle"}).click()
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
|
||||
|
||||
await page.getByRole("button", {name: "Message", exact: true}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(bubble(page, "hi bob")).toBeVisible()
|
||||
|
||||
// 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)
|
||||
await expect(chatItems(page).filter({hasText: "hi bob"})).toContainText("Bob Barnacle")
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// Their names rather than their npubs is the client saying it has both profiles, which is what
|
||||
// the search she is about to type into is built from.
|
||||
// Their names rather than their npubs is the client saying it has both profiles.
|
||||
await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle")
|
||||
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
|
||||
await expect(pageBar(page)).toContainText("Bob Barnacle")
|
||||
await expect(pageBar(page)).toContainText("Carol Cutter")
|
||||
|
||||
await pageBar(page)
|
||||
.getByRole("button", {name: /Bob Barnacle/})
|
||||
.click()
|
||||
|
||||
const members = modalBody(page, "People in this conversation")
|
||||
|
||||
await expect(members.getByText("Bob Barnacle")).toBeVisible()
|
||||
await expect(members.getByText("Carol Cutter")).toBeVisible()
|
||||
|
||||
await members.getByRole("button", {name: "Carol Cutter"}).click()
|
||||
|
||||
await expect(members).toHaveCount(0)
|
||||
await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(profilePath(users.carol.pubkey)))
|
||||
})
|
||||
|
||||
test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
|
||||
|
|
@ -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
|
||||
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()
|
||||
|
||||
|
|
@ -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)))
|
||||
|
||||
// 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)
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
|
|
@ -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}) => {
|
||||
let yesterday!: SeededRumor
|
||||
let firstToday!: SeededRumor
|
||||
let secondToday!: SeededRumor
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
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.bob)
|
||||
|
||||
yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR))
|
||||
// A minute apart, which is what puts these two in the same run of messages.
|
||||
firstToday = space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
|
||||
secondToday = space.dm(user.bob, [user.alice], "still around?", at(49, MINUTE))
|
||||
space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, chatPath(users.bob.pubkey))
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await expect(message(alice, yesterday.id)).toBeVisible()
|
||||
await expect(message(alice, firstToday.id)).toBeVisible()
|
||||
await expect(message(alice, secondToday.id)).toBeVisible()
|
||||
|
||||
// Chronological: each message is rendered below the one before it
|
||||
expect(await topOf(message(alice, yesterday.id))).toBeLessThan(
|
||||
await topOf(message(alice, firstToday.id)),
|
||||
)
|
||||
expect(await topOf(message(alice, firstToday.id))).toBeLessThan(
|
||||
await topOf(message(alice, secondToday.id)),
|
||||
)
|
||||
|
||||
// A divider per day
|
||||
await expect(alice.getByText(await dayLabel(alice, scenario.at(30, HOUR)))).toBeVisible()
|
||||
await expect(alice.getByText(await dayLabel(alice, scenario.at(50, MINUTE)))).toBeVisible()
|
||||
|
||||
// His second message in a row carries neither his name nor his avatar
|
||||
await expect(bubble(alice, "morning alice")).toContainText("Bob Barnacle")
|
||||
await expect(bubble(alice, "morning alice").locator(".rounded-full")).toHaveCount(1)
|
||||
await expect(bubble(alice, "still around?")).not.toContainText("Bob Barnacle")
|
||||
await expect(bubble(alice, "still around?").locator(".rounded-full")).toHaveCount(0)
|
||||
await expect(bubble(alice, "morning alice")).toBeVisible()
|
||||
|
||||
await sendDm(alice, "just got here")
|
||||
|
||||
await expect(bubble(alice, "just got here")).toBeVisible()
|
||||
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
|
||||
// ...and it reaches the conversation he already has open, attributed to her
|
||||
await expect(bubble(bob, "just got here")).toBeVisible()
|
||||
// It reaches the conversation he already has open, attributed to her
|
||||
await expect(bubble(bob, "just got here")).toContainText("Alice Anchor")
|
||||
})
|
||||
|
||||
test("US-033 browse and search your conversations", async ({seed, as}) => {
|
||||
const dave = makeTestUser("dave")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
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)
|
||||
|
||||
const bobStamp = await stampLabel(page, scenario.at(30, MINUTE))
|
||||
const carolStamp = await stampLabel(page, scenario.at(3, HOUR))
|
||||
const daveStamp = await stampLabel(page, scenario.at(2, DAY))
|
||||
|
||||
// Most recently active first, each with a name, a preview and a timestamp
|
||||
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
|
||||
// Most recently active first
|
||||
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?")
|
||||
await expect(chatItems(page).nth(0)).toContainText(bobStamp)
|
||||
await expect(chatItems(page).nth(1)).toContainText("Carol Cutter")
|
||||
await expect(chatItems(page).nth(1)).toContainText("thanks for the link")
|
||||
await expect(chatItems(page).nth(1)).toContainText(carolStamp)
|
||||
await expect(chatItems(page).nth(2)).toContainText("Dave Davits")
|
||||
await expect(chatItems(page).nth(2)).toContainText("see you at the meetup")
|
||||
await expect(chatItems(page).nth(2)).toContainText(daveStamp)
|
||||
|
||||
await chatFilter(page).fill("Bob Barnacle")
|
||||
|
||||
await expect(chatItems(page)).toHaveCount(1)
|
||||
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
|
||||
|
||||
await chatFilter(page).fill("")
|
||||
|
||||
await expect(chatItems(page)).toHaveCount(3)
|
||||
})
|
||||
|
||||
test("US-034 track and clear unread conversations", async ({seed, as}) => {
|
||||
|
|
@ -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))
|
||||
})
|
||||
|
||||
// A touch context, which is what puts a message's actions behind named buttons rather than
|
||||
// behind a row of icons in a hover popover.
|
||||
// A touch context, which puts a message's actions behind named buttons rather than a hover popover.
|
||||
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await expect(message(alice, his.id)).toBeVisible()
|
||||
await expect(message(alice, hers.id)).toBeVisible()
|
||||
|
||||
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 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(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
|
||||
// leads with React and Reply and keeps the rest behind a disclosure.
|
||||
// The menu leads with React and Reply and keeps the rest behind a disclosure.
|
||||
await openMessageMenu(alice, hers.id)
|
||||
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 expect(composePreview(alice)).toContainText("Editing message")
|
||||
await expect(composer(alice)).toContainText("first attempt")
|
||||
|
||||
await composer(alice).press("ControlOrMeta+a")
|
||||
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(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
|
||||
|
||||
// His, hers and her reply — the edit replaced one rather than adding a fourth
|
||||
await expect(alice.locator(".chat-bubble")).toHaveCount(3)
|
||||
await expect(bob.locator(".chat-bubble")).toHaveCount(3)
|
||||
|
||||
// React, then take it back, on both sides of the conversation
|
||||
await openMessageMenu(alice, his.id)
|
||||
await alice.getByRole("button", {name: "React"}).click()
|
||||
|
||||
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
// A result's label is the emoji, its annotation and every shortcode joined together, so the
|
||||
// annotation is what gets matched rather than the whole of it.
|
||||
// A result's label joins the emoji, its annotation and every shortcode.
|
||||
await picker.locator("input.search").fill("party popper")
|
||||
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 expect(message(alice, his.id)).not.toContainText("🎉")
|
||||
await expect(message(bob, his.id)).not.toContainText("🎉")
|
||||
|
||||
// Up in an empty composer picks up the last thing she said, which is the reply
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
|
||||
await composer(alice).press("ArrowUp")
|
||||
|
||||
await expect(composePreview(alice)).toContainText("Editing message")
|
||||
await expect(composer(alice)).toContainText("yes I did")
|
||||
})
|
||||
|
||||
test("US-036 receive a new conversation live", async ({seed, as}) => {
|
||||
|
|
@ -611,8 +399,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
|
|||
space.messagingRelayList(user.alice)
|
||||
space.messagingRelayList(user.bob)
|
||||
|
||||
// A conversation each of them already has. A list with something in it is how each page says
|
||||
// its own end of the sync is up, before the one that has to arrive live is sent.
|
||||
// A list with something in it is how each page says its own end of the sync is up.
|
||||
space.dm(user.carol, [user.alice], "see you monday", 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")
|
||||
|
||||
// His own copy first, so a send that lost keystrokes to something else on the page fails here
|
||||
// rather than thirty seconds later as a message that never reached her
|
||||
// His own copy first, so a send that lost keystrokes fails here rather than thirty seconds later.
|
||||
await expect(bubble(bob, "starting a chat with you")).toBeVisible()
|
||||
|
||||
// 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 inbox = relay("other")
|
||||
|
||||
// Bob she follows. Carol she knows only through the space they are both in, which is the
|
||||
// weakest thing that still counts as knowing someone.
|
||||
// Carol she knows only through the space they are both in, the weakest thing that counts as knowing.
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
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(),
|
||||
)
|
||||
|
||||
// Dave and Eve write from a relay alice reads her messages on and has not joined, as US-108
|
||||
// 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.
|
||||
// Membership of a relay never reaches a room list, so writing from it vouches for nobody.
|
||||
space.messagingRelayList(user.alice, [space.url, inbox.url])
|
||||
inbox.member(user.alice)
|
||||
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))
|
||||
})
|
||||
|
||||
// Only her space indexes, as in US-108: the socket a client opens before it knows what a relay
|
||||
// is for never identifies itself to it, and the inbox relay serves an anonymous reader nothing.
|
||||
// The socket a client opens before it knows what a relay is for never identifies itself to it.
|
||||
const page = await as(users.alice, "/chat", {
|
||||
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).nth(0)).toContainText("lunch tomorrow?")
|
||||
await expect(chatItems(page).nth(1)).toContainText("the hull is patched")
|
||||
await expect(chatItems(page).nth(2)).toContainText("I am, who is this?")
|
||||
await expect(chatItems(page).filter({hasText: "the hull is patched"})).toBeVisible()
|
||||
await expect(chatItems(page).filter({hasText: "I am, who is this?"})).toBeVisible()
|
||||
|
||||
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")
|
||||
space.messagingRelayList(user.bob)
|
||||
|
||||
// Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and
|
||||
// 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.
|
||||
// Her messaging relay list is the one thing that vouches for her, and the relay serves her nothing until it does.
|
||||
inbox.member(user.alice)
|
||||
|
||||
// Bob's own copy of the wrap is seeded onto the same relay, and this relay authorizes a wrap by
|
||||
// the member its p tag names, so he has to be one too. Nothing else here is his.
|
||||
// This relay authorizes a wrap by the member its p tag names, so bob has to be one too.
|
||||
inbox.member(user.bob)
|
||||
|
||||
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written
|
||||
// by another client is where a url missing its trailing slash comes from, and the relay it
|
||||
// names is the same relay either way.
|
||||
// Tagged verbatim rather than through setUrls, which normalizes on the way in.
|
||||
space.event(user.alice, () =>
|
||||
space
|
||||
.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))
|
||||
})
|
||||
|
||||
// Only her space indexes, so the inbox relay is dialled for her messages and nothing else — the
|
||||
// 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.
|
||||
// Only her space indexes, so the inbox relay is dialled for her messages and nothing else.
|
||||
const page = await as(users.alice, "/chat", {
|
||||
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))
|
||||
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
|
||||
// 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.
|
||||
// A room message comes off the same relay and is read back off the wire every time.
|
||||
space.message(user.bob, "general", "boat is in the water", at(2, HOUR))
|
||||
})
|
||||
|
||||
const url = scenario.space("space").url
|
||||
|
||||
// A touch context, which is what puts Edit Message behind a named button. Editing is the only way
|
||||
// the ui takes a direct message back, and the delete it publishes is the second half of the story.
|
||||
// A touch context, which is what puts Edit Message behind a named button.
|
||||
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()
|
||||
|
|
@ -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(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
|
||||
// lost with them rather than on its own terms.
|
||||
// A reaction travels gift-wrapped the way the messages do, so it is kept or lost with them.
|
||||
await openMessageMenu(alice, his.id)
|
||||
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("🎉")
|
||||
|
||||
// The edit, the delete that retracted the original and the reaction all reach disk in batches, so
|
||||
// waiting for them there is waiting for the whole of what the reload is about to read back.
|
||||
// The edit, the delete and the reaction all reach disk in batches, which is what the reload reads.
|
||||
await expect
|
||||
.poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE))
|
||||
.toEqual(expect.arrayContaining(["the tide charts are up", "thanks"]))
|
||||
|
|
|
|||
160
e2e/specs/emoji-picker.spec.ts
Normal file
160
e2e/specs/emoji-picker.spec.ts
Normal 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("🎉")
|
||||
})
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||
import {DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||
import {POLL, displayRelayUrl} from "@welshman/util"
|
||||
import {RelayMessageType} from "@welshman/net"
|
||||
import {Classified, Comment, FollowList, Note, Poll, Thread} from "@welshman/domain"
|
||||
import {Comment, FollowList, Note, Poll, SearchRelayList, Thread} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {
|
||||
composer,
|
||||
dialog,
|
||||
expect,
|
||||
getTranscript,
|
||||
message,
|
||||
messageActions,
|
||||
modalForm,
|
||||
noteEditor,
|
||||
openRoomDetail,
|
||||
pageBar,
|
||||
pathPattern,
|
||||
|
|
@ -24,54 +24,40 @@ import {
|
|||
} 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 corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape
|
||||
// at h-5 w-5, so the size is part of what says which one this is.
|
||||
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
|
||||
// The primary nav draws its dot in utilities and the space menu's rooms carry secondary-nav__dot.
|
||||
const unreadDot = (scope: Locator) =>
|
||||
scope.locator("div.h-2.w-2.rounded-full.bg-primary, div.secondary-nav__dot")
|
||||
|
||||
// The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url,
|
||||
// so which bell it is can't be read out of the class list, but a room only renders one when it is
|
||||
// muted.
|
||||
// An icon is a css mask built from a data url, so a room only renders this one when it is muted.
|
||||
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
|
||||
|
||||
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
|
||||
// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0.
|
||||
// The only other absolutely-positioned dot in that button is the admin action-items one, at opacity-0.
|
||||
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
|
||||
// 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.
|
||||
// The name comes from nip-11; until that document lands the tooltip is the relay's host instead.
|
||||
const spaceNavItem = (page: Page, name: string) =>
|
||||
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,
|
||||
// since the menu is always on screen there.
|
||||
// The desktop rail has no equivalent, since the menu is always on screen there.
|
||||
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.
|
||||
const spaceMenu = (page: Page, url: string) =>
|
||||
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
|
||||
// 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.
|
||||
// The menu closes on the next mouseup, which a click with no gap between press and release reaches.
|
||||
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
|
||||
|
||||
// Every story here goes on to assert something that depends on the message having landed, so
|
||||
// posting is only finished once it has rendered.
|
||||
// Every story here depends on the message having landed, so posting is finished once it renders.
|
||||
const post = async (page: Page, content: string) => {
|
||||
await send(page, content)
|
||||
await expect(message(page, content)).toBeVisible()
|
||||
}
|
||||
|
||||
// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply,
|
||||
// edit (only on your own recent message), menu.
|
||||
// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, edit, menu.
|
||||
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
|
||||
// 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.
|
||||
// Chromium's own notifications are invisible to a test, and a tab playwright drives is never hidden.
|
||||
const captureNotifications = async (page: Page) => {
|
||||
const notifications: {title: string; body: string}[] = []
|
||||
|
||||
|
|
@ -101,17 +87,14 @@ const captureNotifications = async (page: Page) => {
|
|||
return notifications
|
||||
}
|
||||
|
||||
// The page bar names the room, so waiting for it is what says the composer below now belongs to
|
||||
// the room that was just opened rather than to the one being torn down.
|
||||
// The page bar names the room, so waiting for it says the composer belongs to the room just opened.
|
||||
const postTo = async (page: Page, room: string, content: string) => {
|
||||
await roomLink(page, room).click()
|
||||
await expect(pageBar(page)).toContainText(room)
|
||||
await post(page, content)
|
||||
}
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list: settings are
|
||||
// published to the relays it names, and a gift wrap only reaches somebody who has said where their
|
||||
// messages go.
|
||||
// Settings are published to the relays a person's list names, and a gift wrap needs one too.
|
||||
const seedRelays = (space: SeededSpace, user: TestUser) => {
|
||||
space.relayList(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.bob, "general", "random")
|
||||
|
||||
// History bob wrote himself, so the space has something in it while nothing in it is unread
|
||||
// for him — his own messages never raise an indicator.
|
||||
// History bob wrote himself, since his own messages never raise an indicator.
|
||||
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
|
||||
await expect(unreadDot(navItem)).toBeVisible()
|
||||
|
||||
// Inside the space, the dot points at the room the message landed in. A space's room list only
|
||||
// exists in its own menu, so this is the one indicator the rail can't show.
|
||||
// A space's room list only exists in its own menu, so this is the one indicator the rail can't show.
|
||||
await navItem.click()
|
||||
|
||||
const general = roomLink(bob, "General")
|
||||
const random = roomLink(bob, "Random")
|
||||
|
||||
// Both rooms are on screen first, so a room with no dot is a room raising none rather than a
|
||||
// row that never rendered
|
||||
// Both rooms are on screen first, so a room with no dot is one raising none rather than not rendered.
|
||||
await expect(random).toBeVisible()
|
||||
await expect(unreadDot(general)).toBeVisible()
|
||||
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")
|
||||
|
||||
// Silencing a whole space is only offered once push notifications are on, so that is where alice
|
||||
// has to start.
|
||||
// Silencing a whole space is only offered once push notifications are on.
|
||||
const alice = await as(users.alice, "/settings/alerts", {
|
||||
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")))
|
||||
|
||||
// Silence this one room from its detail panel. Mute is the stronger of the two settings there: it
|
||||
// forces the room's notifications off and hides its unread badges too.
|
||||
// Mute is the stronger of the two settings there: it forces notifications off and hides badges.
|
||||
await openRoomDetail(alice)
|
||||
|
||||
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()
|
||||
|
||||
// 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)
|
||||
|
||||
await openSpaceMenu(menu)
|
||||
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(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}) => {
|
||||
|
|
@ -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
|
||||
const page = await as(users.alice, "/home")
|
||||
|
||||
const addSpace = page.getByRole("link", {name: "Add a space"})
|
||||
const startConversation = page.getByRole("button", {name: "Start a conversation"})
|
||||
|
||||
await expect(addSpace).toBeVisible()
|
||||
await expect(startConversation).toBeVisible()
|
||||
|
||||
await addSpace.click()
|
||||
await page.getByRole("link", {name: "Add a space"}).click()
|
||||
|
||||
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}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
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")
|
||||
|
||||
// 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(unreadDot(conversation)).toBeVisible()
|
||||
|
||||
// A space's threads, events and classifieds are counted per space in Activity rather than listed
|
||||
// as conversations, so the inbox stays a list of messages
|
||||
// Threads, events and classifieds are counted per space in Activity rather than listed as conversations.
|
||||
const activity = page.getByRole("link").filter({hasText: "1 thread"})
|
||||
|
||||
await expect(page.getByRole("heading", {name: "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
|
||||
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}) => {
|
||||
const note = "the tide charts are wrong again"
|
||||
const quiet = "the ferry is running on time"
|
||||
const reply = "they were reprinted last week"
|
||||
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.bob, "general")
|
||||
|
||||
// The feed asks each follow's write relays for their notes, so a note is only reachable
|
||||
// through a relay list naming one.
|
||||
// The feed asks each follow's write relays for their notes, so a note needs a relay list naming one.
|
||||
seedRelays(space, user.alice)
|
||||
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.event(user.bob, () => space.kind(Note).writer().setContent(quiet).renderTemplate())
|
||||
|
||||
space.event(user.bob, () =>
|
||||
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.getByText(note)).toBeVisible()
|
||||
|
||||
// The feed is notes only: a reply is counted on the note it answers rather than drawn
|
||||
// underneath it, and it never gets a card of its own.
|
||||
// The feed is notes only: a reply is counted on the note it answers and gets no card of its own.
|
||||
await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
|
||||
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.
|
||||
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}) => {
|
||||
const note = "the lighthouse has been dark since tuesday"
|
||||
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 indexer = open("indexer")
|
||||
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.join(user.alice, "general")
|
||||
|
||||
// Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere
|
||||
// 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.
|
||||
// Bob's relay is somewhere she writes and nowhere she reads, which is enough to identify to it.
|
||||
indexer.relayList(user.alice, {
|
||||
read: [space.url, indexer.url],
|
||||
write: [space.url, indexer.url, outbox.url],
|
||||
})
|
||||
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
|
||||
// thing that can point the feed at his notes.
|
||||
// Bob is in none of her spaces, so his relay list is the only thing that can point the feed at him.
|
||||
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
|
||||
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 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("Bob Barker")).toBeVisible()
|
||||
|
||||
// The reply is on bob's relay too, and alice reads from her space rather than from that relay,
|
||||
// so a context asked of her own read relays comes back with nothing.
|
||||
// Alice reads from her space rather than bob's relay, so a context asked of hers comes back empty.
|
||||
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}) => {
|
||||
// Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and
|
||||
// then says nothing, which is all the spec asks of it.
|
||||
// Nothing serves this url: the fault is a relay that takes the socket and then says nothing.
|
||||
const stalled = "wss://stalled.test/"
|
||||
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.join(user.alice, "general")
|
||||
|
||||
// The relays the feed will ask are somewhere she writes and nowhere she reads, which is what
|
||||
// lets her client identify to them: zooid answers no REQ without nip-42, and Flotilla only
|
||||
// identifies to relays her own lists name.
|
||||
// Somewhere she writes and nowhere she reads, which is what lets her client identify to them.
|
||||
indexer.relayList(user.alice, {
|
||||
read: [space.url, indexer.url],
|
||||
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")
|
||||
.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
|
||||
// relay it asked is a span one silent relay holds empty.
|
||||
// A span releases what it found once it is done waiting, so one silent relay holds it empty.
|
||||
await expect(network.getByText(note)).toBeVisible({timeout: 20_000})
|
||||
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}) => {
|
||||
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")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("watercooler", {name: "Watercooler"})
|
||||
space.join(user.alice, "general", "watercooler")
|
||||
space.join(user.bob, "general", "watercooler")
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
seedRelays(space, user.alice)
|
||||
|
|
@ -568,32 +500,18 @@ test("US-106 share text into the app", async ({seed, as}) => {
|
|||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// A client only unwraps direct messages once its owner has opened chat, so a conversation the
|
||||
// share dialog can offer is one alice has already seen.
|
||||
// A client only unwraps direct messages once its owner has opened chat.
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
await expect(page.getByText("are you around?").first()).toBeVisible()
|
||||
|
||||
// The text arrives in the query string rather than through a native share intent, which is the
|
||||
// same thing src/routes/share reads either way.
|
||||
// The text arrives in the query string rather than through a native share intent.
|
||||
await page.goto(`/share?text=${encodeURIComponent(shared)}`)
|
||||
|
||||
const share = dialog(page, "Share")
|
||||
|
||||
await expect(page.getByText("Where would you like to share this?")).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "General"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible()
|
||||
|
||||
const search = page.getByPlaceholder("Search rooms and conversations...")
|
||||
|
||||
await search.fill("Watercooler")
|
||||
|
||||
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "General"})).toHaveCount(0)
|
||||
await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0)
|
||||
|
||||
await search.fill("")
|
||||
await share.getByRole("button", {name: "General"}).click()
|
||||
await share.getByRole("button", {name: "Share", exact: true}).click()
|
||||
|
||||
|
|
@ -631,8 +549,7 @@ test("US-107 open a nostr link", async ({seed, as}) => {
|
|||
|
||||
await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible()
|
||||
|
||||
// EventInfo renders the link on mount rather than during setup, so wait for it rather than
|
||||
// copying an empty field
|
||||
// EventInfo renders the link on mount rather than during setup.
|
||||
const eventLink = settingRow(page, "Event Link")
|
||||
|
||||
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 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
|
||||
// activity elsewhere can surface
|
||||
// A phone has no room list on screen while a room is open.
|
||||
const bob = await as(users.bob, roomPath(space.url, "general"), {
|
||||
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()
|
||||
|
||||
// Reading the other space empties the bar, even though bob's own space still has an unread room
|
||||
// — that one is the space's business, and its own indicators carry it
|
||||
// Bob's own space still has an unread room, which is that space's own business.
|
||||
await bob.goto(spacePath(space.url))
|
||||
|
||||
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
|
||||
await expect(unreadDot(menuButton)).toHaveCount(0)
|
||||
})
|
||||
|
||||
// SpaceMenuNavItems offers a content type once the space has an event of that kind or something
|
||||
// under it is unread, so this link appearing at all is what says the seeded content loaded.
|
||||
// SpaceMenuNavItems offers a content type once the space has an event of that kind or an unread.
|
||||
const contentNavItem = (page: Page, name: string) =>
|
||||
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) =>
|
||||
space.event(
|
||||
user,
|
||||
|
|
@ -780,9 +678,7 @@ test("US-112 see which threads are unread", async ({seed, as}) => {
|
|||
|
||||
await expect(his).toBeVisible()
|
||||
|
||||
// syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to
|
||||
// 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.
|
||||
// syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
|
||||
await bob.waitForTimeout(1500)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "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))
|
||||
seedThread(space, user.alice, "the server is on fire", at(2, HOUR))
|
||||
const topic = seedThread(space, user.bob, "where is the sextant", 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 alice = await as(users.alice, "/home")
|
||||
|
||||
// ThreadBoard swaps its table for a list of links when the board is too narrow for the table,
|
||||
// and the two branches render the thread separately, so a dot on one says nothing about the
|
||||
// other.
|
||||
const bob = await as(users.bob, `${spacePath(space.url)}/threads`, {
|
||||
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
||||
})
|
||||
// The room's row is its own chat, whatever the newest thing tagged for the room happens to be
|
||||
const conversation = alice.getByRole("link").filter({hasText: "the server is on fire"})
|
||||
|
||||
const hers = bob.getByRole("link").filter({hasText: "the server is on fire"})
|
||||
const his = bob.getByRole("link").filter({hasText: "where is the sextant"})
|
||||
await expect(conversation).toBeVisible()
|
||||
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
|
||||
// about to clear.
|
||||
await bob.waitForTimeout(1500)
|
||||
await conversation.click()
|
||||
|
||||
await expect(unreadDot(hers)).toBeVisible()
|
||||
await expect(unreadDot(his)).toHaveCount(0)
|
||||
})
|
||||
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
|
||||
|
||||
// Classifieds stands in for the five boards whose items are cards rather than rows — they all
|
||||
// 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")
|
||||
await contentNavItem(alice, "Threads").click()
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
const topic = alice.getByRole("row").filter({hasText: "where is the sextant"})
|
||||
|
||||
seedClassified(space, user.bob, "vintage sextant", at(3, HOUR))
|
||||
seedClassified(space, user.alice, "brass astrolabe", at(2, HOUR))
|
||||
})
|
||||
await expect(topic).toBeVisible()
|
||||
|
||||
const space = scenario.space("space")
|
||||
const bob = await as(users.bob, roomPath(space.url, "general"))
|
||||
// syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
|
||||
await alice.waitForTimeout(1500)
|
||||
|
||||
const classifiedsNav = contentNavItem(bob, "Classifieds")
|
||||
await expect(unreadDot(topic)).toBeVisible()
|
||||
|
||||
await expect(classifiedsNav).toBeVisible()
|
||||
await expect(unreadDot(classifiedsNav)).toBeVisible()
|
||||
await topic.click()
|
||||
|
||||
await classifiedsNav.click()
|
||||
|
||||
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)
|
||||
await expect(alice.getByText("starboard locker, under the charts")).toBeVisible()
|
||||
})
|
||||
|
||||
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.bob, "general")
|
||||
|
||||
// Older than the month the space sync asks for, so the nav can only know about it by asking
|
||||
// the relay what the space holds rather than by reading what has loaded.
|
||||
// Older than the month the space sync asks for, so the nav can only know by asking the relay.
|
||||
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")
|
||||
other.join(user.bob, "general")
|
||||
|
||||
// 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.
|
||||
// A comment on a poll nothing holds: the relay kept the comment and dropped its subject.
|
||||
space.event(
|
||||
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 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 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()
|
||||
|
||||
// Reading it is the end of it: with nothing unread and no poll to list, the space stops
|
||||
// offering the section at all
|
||||
// With nothing unread and no poll to list, the space stops offering the section at all.
|
||||
await roomLink(bob, "General").click()
|
||||
|
||||
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}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
|
@ -987,8 +896,7 @@ test("US-120 read what a notification says", async ({seed, as}) => {
|
|||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// Push notifications are off until they are asked for, and the tab has to be in the background
|
||||
// before one is raised at all.
|
||||
// The tab has to be in the background before a push notification is raised at all.
|
||||
const alice = await as(users.alice, "/settings/alerts", {
|
||||
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()
|
||||
|
||||
// A reply prepends the message it answers, so its first line is an entity and says nothing about
|
||||
// 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.
|
||||
// A reply prepends the message it answers, so its first line is an entity rather than the reply.
|
||||
await expect
|
||||
.poll(() => notifications.at(-1))
|
||||
.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",
|
||||
})
|
||||
})
|
||||
|
||||
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?"}])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type {Page} from "@playwright/test"
|
|||
import {npubEncode, nsecEncode} from "nostr-tools/nip19"
|
||||
import {encrypt} from "nostr-tools/nip49"
|
||||
import {hexToBytes} from "@welshman/lib"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
import {expect, roomPath, test, users} from "../harness"
|
||||
import type {TestUser} from "../harness"
|
||||
|
||||
// The sign-in gate AppContainer renders in place of the app for anyone without a session.
|
||||
|
|
@ -11,8 +11,7 @@ const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotil
|
|||
|
||||
const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret))
|
||||
|
||||
// Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a
|
||||
// session come into existence: a session injected by `as()` is re-applied on every navigation.
|
||||
// The only way to watch a session come into existence: one `as()` injects is re-applied on every navigation.
|
||||
const logInWithKey = async (page: Page, key: string) => {
|
||||
await page.getByRole("button", {name: "Log in"}).click()
|
||||
await page.getByRole("button", {name: "Log in with Key"}).click()
|
||||
|
|
@ -21,14 +20,11 @@ const logInWithKey = async (page: Page, key: string) => {
|
|||
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
|
||||
// icon is a masked svg with no alt text — so it is addressed by where it goes.
|
||||
// The nav's settings link carries its label as a tooltip, so it is addressed by where it goes.
|
||||
const openSettings = (page: Page) =>
|
||||
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
|
||||
// below it, so `getByRole("textbox")` on this page is two elements — the npub is the readonly one
|
||||
// that isn't a password.
|
||||
// A nip01 login renders a masked Private Key too, so the npub is the readonly non-password one.
|
||||
const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])')
|
||||
|
||||
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")
|
||||
|
||||
// A room url rather than the root: the gate stands in front of the whole app, so where the
|
||||
// visitor landed makes no difference.
|
||||
// A room url rather than the root: the gate stands in front of the whole app.
|
||||
const page = await visit(roomPath(url, "general"))
|
||||
|
||||
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 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 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}) => {
|
||||
const scenario = await seed(({relay}) => {
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await visit()
|
||||
|
||||
await page.getByRole("button", {name: "Create an account"}).click()
|
||||
await page.getByRole("button", {name: "Generate a key"}).click()
|
||||
|
||||
await page.locator('input[type="text"]').fill("Nova Tester")
|
||||
await page.getByRole("textbox", {name: "Nickname"}).fill("Nova Tester")
|
||||
await page.getByRole("button", {name: "Create Account"}).click()
|
||||
|
||||
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 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 expect(page.getByText("Nova Tester")).toBeVisible()
|
||||
|
||||
await page.goto("/home")
|
||||
await expect(page.getByText("Back Up Your Key")).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(page.getByText("Back Up Your Key")).toBeVisible()
|
||||
|
||||
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()
|
||||
await page
|
||||
.getByRole("group", {name: "Back Up Your Key"})
|
||||
.getByRole("button", {name: "Fix"})
|
||||
.click()
|
||||
|
||||
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 password.fill("hunter2")
|
||||
await page.getByRole("button", {name: "Download my key"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Your password must be at least 12 characters long.",
|
||||
)
|
||||
await expect(doneButton).toBeDisabled()
|
||||
|
||||
await password.fill("correct horse battery staple")
|
||||
await page.locator('input[type="password"]').fill("correct horse battery staple")
|
||||
|
||||
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()
|
||||
|
||||
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 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()
|
||||
|
||||
await withNcryptsec.getByRole("button", {name: "Log in"}).click()
|
||||
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
|
||||
|
||||
const key = withNcryptsec.getByPlaceholder("nsec1...")
|
||||
const submit = withNcryptsec.locator("form").getByRole("button", {name: "Log in", exact: true})
|
||||
|
||||
await key.fill("this is not a key")
|
||||
await expect(submit).toBeDisabled()
|
||||
|
||||
await key.fill(ncryptsec)
|
||||
await withNcryptsec.getByPlaceholder("nsec1...").fill(ncryptsec)
|
||||
await withNcryptsec.getByPlaceholder("Your password").fill("not the password")
|
||||
await submit.click()
|
||||
|
||||
await expect(withNcryptsec.getByRole("alert")).toContainText(
|
||||
"Failed to decrypt key. Please check your password.",
|
||||
)
|
||||
await expect(withNcryptsec.getByRole("heading", {name: "Log In with Key"})).toBeVisible()
|
||||
await expect(withNcryptsec.locator(".primary-nav")).toHaveCount(0)
|
||||
|
||||
await withNcryptsec.getByPlaceholder("Your password").fill(password)
|
||||
await submit.click()
|
||||
|
||||
await expect(withNcryptsec.locator(".primary-nav")).toBeVisible()
|
||||
await expect(withNcryptsec.getByText("Back Up Your Key")).toHaveCount(0)
|
||||
await openSettings(withNcryptsec)
|
||||
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}) => {
|
||||
|
|
@ -262,7 +162,6 @@ test("US-004 log in with a browser extension", async ({seed, visit}) => {
|
|||
await openSettings(page)
|
||||
|
||||
await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey))
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0)
|
||||
|
||||
const withoutExtension = await visit()
|
||||
|
||||
|
|
@ -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 with Remote Signer"}).click()
|
||||
|
||||
const bunker = page.getByPlaceholder("bunker://")
|
||||
const next = page.getByRole("button", {name: "Next"})
|
||||
// The relay named here belongs to no scenario, so a connection attempt is recorded as a leak.
|
||||
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
|
||||
// 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 expect(page.getByRole("alert")).toContainText("invalid bunker link")
|
||||
|
||||
await page.getByRole("button", {name: "Log in with a QR code instead"}).click()
|
||||
|
||||
await expect(page.getByText("Scan with your signer to log in, or click to copy.")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(bunker).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => {
|
||||
|
|
@ -320,32 +204,16 @@ test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) =>
|
|||
space.join(user.alice, "general")
|
||||
})
|
||||
|
||||
const npub = npubEncode(users.alice.pubkey)
|
||||
const page = await visit()
|
||||
|
||||
await logInWithKey(page, nsecFor(users.alice))
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
|
||||
await openSettings(page)
|
||||
|
||||
await expect(npubField(page)).toHaveValue(npub)
|
||||
await expect(npubField(page)).toHaveValue(npubEncode(users.alice.pubkey))
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
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 expect(npubField(page)).toHaveValue(npubEncode(users.alice.pubkey))
|
||||
|
||||
await page.locator(".secondary-nav").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()
|
||||
})
|
||||
|
||||
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}) => {
|
||||
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)
|
||||
|
||||
await expect(npubInput).toHaveValue(npub)
|
||||
await expect(npubInput).toHaveJSProperty("readOnly", true)
|
||||
|
||||
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))
|
||||
|
||||
const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last()
|
||||
|
||||
await expect(signerStatus).toContainText("Logged in with private key")
|
||||
await expect(signerStatus).toContainText("Ok")
|
||||
|
||||
const succeeded = async () =>
|
||||
Number(/(\d+) requests succeeded/.exec(await signerStatus.innerText())?.[1])
|
||||
|
||||
const before = await succeeded()
|
||||
|
||||
await page.locator(".card").first().getByRole("button").click()
|
||||
await page.locator('input[type="text"]').first().fill("Alice Anderson")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
|
||||
await expect.poll(succeeded).toBeGreaterThan(before)
|
||||
|
||||
// An extension session holds no key for the app to show.
|
||||
const extension = await visit("/", {nip07: users.bob})
|
||||
|
||||
await extension.getByRole("button", {name: "Log in"}).click()
|
||||
await extension.getByRole("button", {name: "Log in with Extension"}).click()
|
||||
|
||||
await expect(extension.locator(".primary-nav")).toBeVisible()
|
||||
|
||||
await openSettings(extension)
|
||||
|
||||
await expect(extension.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey))
|
||||
await expect(extension.locator('input[type="password"]')).toHaveCount(0)
|
||||
await expect(extension.locator(".card").filter({hasText: "Signer Status"}).last()).toContainText(
|
||||
"Logged in with browser extension",
|
||||
)
|
||||
await expect(page.getByText("Logged in with private key")).toBeVisible()
|
||||
})
|
||||
|
||||
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 phrase.fill("permanently delete my")
|
||||
|
||||
await expect(confirm).toBeDisabled()
|
||||
|
||||
await phrase.fill("permanently delete my nostr account")
|
||||
|
||||
await expect(confirm).toBeEnabled()
|
||||
|
||||
await confirm.click()
|
||||
|
||||
await expect(alice.getByRole("progressbar")).toBeVisible()
|
||||
await expect(gate(alice)).toBeVisible()
|
||||
|
||||
const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`)
|
||||
|
||||
// zooid honours the kind-62 right-to-vanish, so alice's profile — including the "[deleted]" name
|
||||
// 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).
|
||||
// zooid honours the kind-62 right-to-vanish, so the profile is gone rather than renamed.
|
||||
const aliceNpub = npubEncode(users.alice.pubkey)
|
||||
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
|
||||
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {nprofileEncode, npubEncode} from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE, MONTH} from "@welshman/lib"
|
||||
import {NOTE, STATUS, makeEvent} from "@welshman/util"
|
||||
import {npubEncode} from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE, MONTH, uniq} from "@welshman/lib"
|
||||
import {NOTE, PROFILE, STATUS, makeEvent} from "@welshman/util"
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
|
||||
import {
|
||||
|
|
@ -10,47 +10,37 @@ import {
|
|||
GIF,
|
||||
WEBP,
|
||||
expect,
|
||||
makeTestUser,
|
||||
getPublished,
|
||||
getPublishedEvents,
|
||||
mockBlossom,
|
||||
mockGeocoding,
|
||||
noteEditor,
|
||||
profilePath,
|
||||
spacePath,
|
||||
test,
|
||||
topDialog,
|
||||
users,
|
||||
} from "../harness"
|
||||
import type {TestUser} from "../harness"
|
||||
|
||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
||||
|
||||
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above
|
||||
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
|
||||
// The Reputation and Spaces panels only exist above tailwind's xl breakpoint, where 1280 sits.
|
||||
const DESKTOP = {viewport: {width: 1440, height: 900}}
|
||||
|
||||
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
|
||||
|
||||
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never
|
||||
// does.
|
||||
// uploadFile appends the extension when the descriptor's url carries none, as the mock's never does.
|
||||
const uploadedUrl = (body: Buffer, extension: string) =>
|
||||
`${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
|
||||
|
||||
// displayPubkey in @welshman/domain: the npub with its middle taken out.
|
||||
const shortNpub = (user: TestUser) => {
|
||||
const npub = npubEncode(user.pubkey)
|
||||
|
||||
return npub.slice(0, 8) + "…" + npub.slice(-5)
|
||||
}
|
||||
|
||||
// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the
|
||||
// nav renders.
|
||||
// The page's own region, so an assertion about an avatar isn't satisfied by the copy the nav renders.
|
||||
const pageContent = (page: Page) => page.locator(".page__content")
|
||||
|
||||
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in
|
||||
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
|
||||
// ProfileTrust and ProfileSharedSpaces are rendered twice, stacked for narrow viewports and here.
|
||||
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
|
||||
// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it.
|
||||
// A space path redirects as the page mounts, and modal.ts closes every open modal on navigation.
|
||||
const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/."))
|
||||
|
||||
// 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()
|
||||
|
||||
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 space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
// Bob is the one person whose name shares no token with the others, so narrowing the term is
|
||||
// visibly a filter rather than a reordering.
|
||||
space.profile(user.bob, {
|
||||
name: "Bob Barnacle",
|
||||
about: "Dockside cook and keeper of the ship's cat.",
|
||||
})
|
||||
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
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 term = searchTerm(page)
|
||||
const cards = searchResults(page)
|
||||
const bobCard = searchResults(page).filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await enteredSpace(page)
|
||||
await openSearch(page)
|
||||
await term.fill("Searcher")
|
||||
|
||||
// Which of them ranks first is fuse's business, so a result is described by what every
|
||||
// result carries rather than by which one it turned out to be.
|
||||
const first = cards.first()
|
||||
|
||||
await expect(first).toBeVisible()
|
||||
await expect(first.locator('img[src^="https://images.test/searcher-"]')).toBeVisible()
|
||||
await expect(first).toContainText(/Searcher \d\d/)
|
||||
await expect(first).toContainText("Deck crew on the northern run.")
|
||||
|
||||
await expect.poll(() => cards.count()).toBe(10)
|
||||
|
||||
// Narrowing the term filters the list rather than reordering it.
|
||||
await term.fill("Barnacle")
|
||||
|
||||
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
||||
await searchTerm(page).fill("Barnacle")
|
||||
|
||||
await expect(bobCard).toHaveCount(1)
|
||||
await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0)
|
||||
|
||||
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}) => {
|
||||
const avatar = "https://images.test/bob-avatar.png"
|
||||
const banner = "https://images.test/bob-banner.png"
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
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.bob, "general")
|
||||
|
||||
// A second space alice does not belong to, so "Member" is a claim about the overlap rather
|
||||
// than about every space bob is in.
|
||||
// A second space alice does not belong to, so "Member" is a claim about the overlap.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.bob, "lounge")
|
||||
|
||||
|
|
@ -161,9 +105,6 @@ test("US-075 view someone's profile", async ({seed, as}) => {
|
|||
.writer()
|
||||
.setName("Bob Barnacle")
|
||||
.setAbout("Deckhand, dockside cook, and keeper of the ship's cat.")
|
||||
.setPicture(avatar)
|
||||
.setBanner(banner)
|
||||
.setWebsite("bobbarnacle.example")
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
|
|
@ -180,11 +121,9 @@ test("US-075 view someone's profile", async ({seed, as}) => {
|
|||
|
||||
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.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
|
||||
// up to any more.
|
||||
// An expired status is one its author asked relays to stop serving.
|
||||
space.event(
|
||||
user.carol,
|
||||
makeEvent(STATUS, {
|
||||
|
|
@ -200,50 +139,27 @@ test("US-075 view someone's profile", async ({seed, as}) => {
|
|||
|
||||
const space = scenario.space("space")
|
||||
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)
|
||||
|
||||
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.getByRole("link", {name: "Scrubbing the decks"})).toHaveAttribute(
|
||||
"href",
|
||||
"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 spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`)
|
||||
const otherLink = spaces.locator(`a[href="${spacePath(other.url)}"]`)
|
||||
|
||||
await expect(spaces.locator(".badge").first()).toHaveText("2")
|
||||
await expect(spaceLink).toContainText("Member")
|
||||
await expect(otherLink).toBeVisible()
|
||||
await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0)
|
||||
|
||||
await spaceLink.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
|
||||
await expect(otherLink).not.toContainText("Member")
|
||||
|
||||
await page.goto(profilePath(users.carol.pubkey))
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
|
|
@ -267,20 +183,14 @@ test("US-076 follow and unfollow", async ({seed, as}) => {
|
|||
|
||||
await follow.click()
|
||||
|
||||
// The same page, still on the same url: the label flips where it stands.
|
||||
await expect(unfollow).toBeVisible()
|
||||
await expect(follow).toHaveCount(0)
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
||||
|
||||
await unfollow.click()
|
||||
|
||||
await expect(follow).toBeVisible()
|
||||
await expect(unfollow).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||
const carolAvatar = "https://images.test/carol-avatar.png"
|
||||
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
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.carol, "general")
|
||||
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.bob)
|
||||
space.relayList(user.carol)
|
||||
|
||||
// Carol already follows bob, so alice following carol is the one thing that has to happen
|
||||
// through the ui for his standing to move.
|
||||
// Carol already follows bob, so alice following carol is the one thing that goes through the ui.
|
||||
space.event(user.carol, () =>
|
||||
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
|
||||
)
|
||||
})
|
||||
|
||||
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
|
||||
// in the client that published it.
|
||||
// Every hop here is a link click rather than a navigation, so the follow stays in one client.
|
||||
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
|
||||
const term = searchTerm(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 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 openSearch(page)
|
||||
await term.fill("Barnacle")
|
||||
|
||||
await expect(bobCard).toHaveCount(1)
|
||||
await expect(bobCard.locator("circle.wot-highlight")).toBeAttached()
|
||||
|
||||
const emptyRing = await ring()
|
||||
|
||||
await viewProfile(bobCard).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
||||
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are
|
||||
// exactly the readings this is supposed to rule out.
|
||||
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100".
|
||||
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
|
||||
await expect(reputation()).toContainText("This user is not well known in your network.")
|
||||
|
||||
await openSearch(page)
|
||||
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 expect(bobCard).toHaveCount(1)
|
||||
await expect.poll(ring).toBeLessThan(emptyRing)
|
||||
|
||||
await viewProfile(bobCard).click()
|
||||
|
||||
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
|
||||
await expect(reputation()).toContainText("1 person you follow also follows Bob Barnacle.")
|
||||
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-078 edit your own profile", async ({seed, as}) => {
|
||||
const oldAvatar = "https://images.test/alice-avatar.png"
|
||||
const newAvatar = uploadedUrl(WEBP, "webp")
|
||||
const newBanner = uploadedUrl(GIF, "gif")
|
||||
|
||||
|
|
@ -373,8 +268,6 @@ test("US-078 edit your own profile", async ({seed, as}) => {
|
|||
.writer()
|
||||
.setName("Alice Anderson")
|
||||
.setAbout("Ship's navigator.")
|
||||
.setPicture(oldAvatar)
|
||||
.setNip05("alice@flotilla.test")
|
||||
.renderTemplate(),
|
||||
)
|
||||
space.relayList(user.alice)
|
||||
|
|
@ -387,24 +280,14 @@ test("US-078 edit your own profile", async ({seed, as}) => {
|
|||
const region = pageContent(page)
|
||||
const edit = page.getByRole("button", {name: "Edit profile"})
|
||||
|
||||
await expect(edit).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Follow", exact: true})).toHaveCount(0)
|
||||
// Exact, or this would match the nav's "Messages".
|
||||
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
|
||||
|
||||
await edit.click()
|
||||
|
||||
// ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address.
|
||||
const form = page.locator("form").first()
|
||||
const nickname = form.locator('input[type="text"]').first()
|
||||
const nickname = form.getByRole("textbox", {name: "Nickname"})
|
||||
const about = form.locator("textarea")
|
||||
const nostrAddress = form.locator('input[type="text"]').nth(2)
|
||||
const picker = page.locator('label[aria-label="Drag and drop files here."]')
|
||||
|
||||
await expect(nickname).toHaveValue("Alice Anderson")
|
||||
await expect(about).toHaveValue("Ship's navigator.")
|
||||
await expect(nostrAddress).toHaveValue("alice@flotilla.test")
|
||||
await expect.poll(() => picker.getAttribute("style")).toContain(oldAvatar)
|
||||
|
||||
await nickname.fill("Alice Anchor")
|
||||
await about.fill("Ship's navigator and part-time cartographer.")
|
||||
|
|
@ -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}) => {
|
||||
const avatar = "https://images.test/alice-avatar.png"
|
||||
|
||||
let carolNote!: Seeded
|
||||
let pinned!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson", picture: avatar})
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.carol, {name: "Carol Cutter"})
|
||||
space.relayList(user.alice)
|
||||
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)}),
|
||||
)
|
||||
|
||||
// Older than the feed's first window, so the pin is fetched by id while the feed only
|
||||
// reaches it by paging — the order in which one note arrives down both routes.
|
||||
// Older than the feed's first window, so the pin is fetched by id while the feed only pages to it.
|
||||
pinned = space.event(
|
||||
user.alice,
|
||||
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 list = notes(page)
|
||||
const newest = list.filter({hasText: "NEWEST"})
|
||||
|
||||
await expect(newest).toBeVisible()
|
||||
await expect(newest.getByText("Alice Anderson")).toBeVisible()
|
||||
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
|
||||
// The story asks for a relative timestamp; 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: "NEWEST"})).toBeVisible()
|
||||
await expect(list.filter({hasText: "MIDDLE"})).toBeVisible()
|
||||
|
||||
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
|
||||
|
||||
// The pin outranks the newer notes; the rest are newest first.
|
||||
// The pin outranks the newer notes.
|
||||
await expect(list.first()).toContainText("PINNED")
|
||||
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
|
||||
// what "loads older notes automatically" means.
|
||||
// Nothing is scrolled here: the feed keeps widening its window until the page is full.
|
||||
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
|
||||
// it goes out through the app's own primitives in her signed-in session, over her socket to the
|
||||
// space — which is the half of this the story is about.
|
||||
const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
|
||||
test("US-134 write a public post", async ({seed, as}) => {
|
||||
const post = "the wind has finally turned"
|
||||
|
||||
await alice.evaluate(
|
||||
async ([relayUrl, content]) => {
|
||||
const {
|
||||
app,
|
||||
network,
|
||||
makeEvent: make,
|
||||
} = window as unknown as {
|
||||
app: {get(): {user: {sign(template: object): Promise<object>}}}
|
||||
network: {get(): {publish(options: {event: object; relays: string[]}): Promise<unknown>}}
|
||||
makeEvent(kind: number, values: {content: string}): object
|
||||
}
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
await network.get().publish({
|
||||
event: await app.get().user.sign(make(1, {content})),
|
||||
relays: [relayUrl],
|
||||
})
|
||||
},
|
||||
[url, "LIVE straight off the deck"] as const,
|
||||
)
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.relayList(user.alice)
|
||||
|
||||
// The profile feed fetches on load rather than subscribing live, so bob sees it the next time he
|
||||
// opens the page — where it lands above every unpinned note, below the pin.
|
||||
await page.reload()
|
||||
// Her second space is in no relay list of hers, so a post reaching it was routed there as a space.
|
||||
other.room("general", {name: "General"})
|
||||
other.join(user.alice, "general")
|
||||
})
|
||||
|
||||
await expect(list.filter({hasText: "LIVE"})).toBeVisible()
|
||||
await expect(list.nth(1)).toContainText("LIVE")
|
||||
const page = await as(users.alice, profilePath(users.alice.pubkey), {context: DESKTOP})
|
||||
|
||||
// Carol's own note is on her profile; alice's reply to it is alice's, so it isn't here.
|
||||
await page.goto(profilePath(users.carol.pubkey))
|
||||
await page.getByRole("button", {name: "Create post"}).click()
|
||||
|
||||
await expect(notes(page).filter({hasText: "Anyone seen the tide charts?"})).toBeVisible()
|
||||
await expect(notes(page).filter({hasText: "REPLY"})).toHaveCount(0)
|
||||
const composer = topDialog(page)
|
||||
|
||||
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}) => {
|
||||
const avatar = "https://images.test/bob-avatar.png"
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
|
|
@ -582,22 +464,13 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
|
|||
space.profile(user.bob, {
|
||||
name: "Bob Barnacle",
|
||||
about: "Deckhand, dockside cook, and keeper of the ship's cat.",
|
||||
picture: avatar,
|
||||
})
|
||||
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR))
|
||||
space.event(
|
||||
user.bob,
|
||||
makeEvent(STATUS, {content: "Scrubbing the decks", tags: [["d", "general"]]}),
|
||||
)
|
||||
space.relayList(user.bob)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP})
|
||||
// SpaceMember covers its whole card with one button, whose aria-label is interpolated from
|
||||
// `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at
|
||||
// first render, which is the npub the card falls back to before the profile has loaded. The
|
||||
// name it *displays* comes from a store and does update, so the card is found by that.
|
||||
// The card's aria-label keeps the npub it fell back to at first render, so it is found by the name.
|
||||
const preview = page
|
||||
.locator(".card.card-interactive")
|
||||
.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"})
|
||||
|
||||
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("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 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.
|
||||
await expect(page.locator(".dialog")).toHaveCount(0)
|
||||
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
|
||||
await expect(preview).toBeVisible()
|
||||
|
||||
await preview.click()
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
|
@ -663,63 +518,25 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
|
|||
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
|
||||
|
||||
const info = topDialog(page)
|
||||
// Each of these is an input and its copy button inside one bordered label, in dialog order.
|
||||
const linkField = info.locator("label.input").first()
|
||||
// The pubkey and its copy button share the dialog's second bordered label.
|
||||
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
|
||||
// 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 expect(pubkeyField.locator('input[type="text"]')).toHaveValue(npubEncode(users.bob.pubkey))
|
||||
|
||||
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 info.getByRole("button", {name: "Copy"}).click()
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toContain("Bob Barnacle")
|
||||
|
||||
await page.getByRole("button", {name: "Got it"}).click()
|
||||
|
||||
await profileMenu(page).click()
|
||||
await page.getByRole("button", {name: "Share"}).click()
|
||||
|
||||
// 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 qr = share.locator("canvas")
|
||||
const shareLink = share.locator("input")
|
||||
|
||||
await expect(qr).toBeVisible()
|
||||
expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0)
|
||||
|
||||
// pubkeyLink in src/app/env.ts, which shares a profile without relay hints.
|
||||
await expect(shareLink).toHaveValue(
|
||||
`https://coracle.social/${nprofileEncode({pubkey: users.bob.pubkey, relays: []})}`,
|
||||
)
|
||||
|
||||
await share.locator("label.input").getByRole("button").click()
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(await shareLink.inputValue())
|
||||
await expect(share.locator("canvas")).toBeVisible()
|
||||
await expect(share.locator("input")).toHaveValue(/nprofile1/)
|
||||
})
|
||||
|
||||
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 save = page.getByRole("button", {name: "Save Changes"})
|
||||
|
||||
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is
|
||||
// rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a
|
||||
// pause in the typing has let the query go out.
|
||||
// Typed slower than the search's own debounce, since the profile only arrives after a pause.
|
||||
await page
|
||||
.getByPlaceholder("Search for profiles...")
|
||||
.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!")
|
||||
|
||||
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
|
||||
// just published the mute believes.
|
||||
// Through the nav rather than a fresh navigation, so this is what the publishing client believes.
|
||||
const openBobsProfile = async () => {
|
||||
await openSearch(page)
|
||||
await searchTerm(page).fill("Barnacle")
|
||||
|
|
@ -785,21 +599,13 @@ test("US-082 mute an account", async ({seed, as}) => {
|
|||
const list = notes(page)
|
||||
|
||||
await expect(list).toHaveCount(2)
|
||||
await expect(list.first()).toContainText("You have muted this person.")
|
||||
await expect(list.last()).toContainText("You have muted this person.")
|
||||
await expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0)
|
||||
|
||||
await list.first().getByRole("button", {name: "Show anyway"}).click()
|
||||
|
||||
await expect(list.first()).toContainText("The gulls have taken the mainsail")
|
||||
await expect(list.last()).toContainText("You have muted this person.")
|
||||
|
||||
await page.locator('.primary-nav a[href="/settings/profile"]').click()
|
||||
await page.getByRole("link", {name: "Content"}).click()
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await page.goto("/settings/content")
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
|
|
@ -816,5 +622,144 @@ test("US-082 mute an account", async ({seed, as}) => {
|
|||
await expect(list).toHaveCount(2)
|
||||
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible()
|
||||
await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible()
|
||||
await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 {Profile, displayPubkey} from "@welshman/domain"
|
||||
import {Profile} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {
|
||||
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.
|
||||
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
|
||||
// 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.
|
||||
// Each of these menus hides itself once the pointer leaves it, and the top left corner is outside.
|
||||
const dismissMenu = (page: Page) => page.mouse.move(0, 0)
|
||||
|
||||
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) =>
|
||||
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.
|
||||
const field = (form: Locator, label: string) =>
|
||||
form
|
||||
|
|
@ -63,8 +46,7 @@ const field = (form: Locator, label: string) =>
|
|||
.locator("xpath=following-sibling::div")
|
||||
.locator("input[type=text]")
|
||||
|
||||
// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled
|
||||
// control.
|
||||
// RoomForm's permission toggles are a bare checkbox beside their own text.
|
||||
const permission = (form: Locator, label: string) =>
|
||||
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")
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, and a gift wrap only
|
||||
// reaches somebody who has said where their messages go.
|
||||
// Outbox routing resolves a person through their relay list, and a gift wrap needs one too.
|
||||
const seedChatter = (space: SeededSpace, user: TestUser) => {
|
||||
space.relayList(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.join(user.alice, "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))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
// Two browser contexts, two identities, one relay: bob's page is already listening when alice
|
||||
// sends, so the message reaches him over the wire.
|
||||
// Two browser contexts, two identities, one relay: bob's page is already listening when alice sends.
|
||||
const alice = await as(users.alice, 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("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()
|
||||
|
||||
|
|
@ -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).pressSequentially("second line")
|
||||
|
||||
await expect(composer(alice).locator("br")).toHaveCount(1)
|
||||
await expect(messages(alice)).toHaveCount(2)
|
||||
|
||||
// ...and cmd/ctrl+enter sends from the middle of it
|
||||
await composer(alice).press("ArrowUp")
|
||||
await composer(alice).press("ControlOrMeta+Enter")
|
||||
|
|
@ -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")
|
||||
})
|
||||
|
||||
// Two people typing in the same second used to leave every client with a different transcript,
|
||||
// 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.
|
||||
// Two messages sharing a second are ordered by event id, which is the same on every client.
|
||||
test("US-118 messages sent in the same second are in one order for everyone", async ({
|
||||
seed,
|
||||
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")),
|
||||
)
|
||||
|
||||
// The room reads newest first in the dom, so the ids run the other way from the order the feed
|
||||
// holds them in
|
||||
// The room reads newest first in the dom, so the ids run the other way from the feed.
|
||||
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)
|
||||
|
||||
await expect(message(bob, "morning all")).toBeVisible()
|
||||
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
|
||||
|
||||
await openRoomDetail(bob)
|
||||
await openRoomDetailMenu(bob)
|
||||
await bob.getByRole("button", {name: "Join member list"}).click()
|
||||
|
||||
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
|
||||
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 ones naming itself, so somebody else's arrival is read with the rest of the room.
|
||||
// The membership events a client listens for live are the ones naming itself.
|
||||
await alice.reload()
|
||||
|
||||
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(joined).toBeVisible()
|
||||
|
||||
await joined.getByRole("button", {name: "@Bob Barnacle"}).click()
|
||||
|
||||
await expect(alice.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
await expect(alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})).toBeVisible()
|
||||
|
||||
await openRoomDetailMenu(bob)
|
||||
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 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}) => {
|
||||
|
|
@ -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 expect(pageBar(admin)).toContainText("Ship Log")
|
||||
await expect.poll(() => roomSection(admin, "Ship Log")).toBe("Your Rooms")
|
||||
|
||||
// The room id is generated, so it comes back off the url the form navigated to
|
||||
const h = new URL(admin.url()).pathname.split("/").pop()!
|
||||
|
|
@ -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 openRoomDetailMenu(admin)
|
||||
await admin.getByRole("button", {name: "Edit Room"}).click()
|
||||
|
||||
await expect(field(editForm, "Name")).toHaveValue("Captain's Log")
|
||||
await expect(permission(editForm, "Only allow members to read messages")).toBeChecked()
|
||||
|
||||
await editForm.getByRole("button", {name: "Go back"}).click()
|
||||
await openRoomDetailMenu(admin)
|
||||
await admin.getByRole("button", {name: "Delete Room"}).click()
|
||||
|
||||
await expect(
|
||||
admin.getByRole("heading", {name: "Are you sure you want to delete this room?"}),
|
||||
).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin).not.toHaveURL(pathPattern(h))
|
||||
|
|
@ -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: "Confirm"}).click()
|
||||
|
||||
// The space root renders nothing on a wide screen; it hands off to whichever page of the space
|
||||
// was open last, and the room that was just deleted is the page it remembers.
|
||||
// The space root renders nothing on a wide screen, handing off to the page of the space open last.
|
||||
await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/"))
|
||||
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")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
// `private` is what hides the room's history from non-members; `closed` is what keeps a join
|
||||
// request from admitting her on its own, so an admin has to act on it.
|
||||
// `private` hides the room's history from non-members; `closed` makes a join a request.
|
||||
space.room("wardroom", {name: "Wardroom", private: true, closed: true})
|
||||
space.join(user.admin, "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("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
|
||||
// an admin has to act on.
|
||||
// A private room offers to join rather than to ask, and a closed one turns that into a request.
|
||||
await carol.getByRole("button", {name: "Join Room"}).click()
|
||||
|
||||
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()
|
||||
|
||||
// The history the relay refused her is fetched when the room is next read, so this is what she
|
||||
// finds on her way back in rather than something that fills in behind her.
|
||||
// The history the relay refused her is fetched when the room is next read.
|
||||
await carol.reload()
|
||||
|
||||
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}) => {
|
||||
const dora = makeTestUser("dora")
|
||||
const erik = makeTestUser("erik")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
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.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
// A space member who isn't in the room yet. Her message is what loads her profile into the
|
||||
// client that goes looking for her.
|
||||
// A space member who isn't in the room yet, whose message loads her profile into the client.
|
||||
space.join(dora)
|
||||
space.profile(dora, {name: "Dora Deckhand"})
|
||||
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 admin.getByRole("button", {name: "Create invite"}).click()
|
||||
|
||||
const inviteModal = dialog(admin, "Create a Room Invite")
|
||||
// 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]")
|
||||
const invite = dialog(admin, "Create a Room Invite").locator("input[readonly]")
|
||||
|
||||
await expect(inviteModal.locator("canvas")).toBeVisible()
|
||||
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
|
||||
|
||||
await inviteField.getByRole("button").click()
|
||||
|
||||
await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
|
||||
|
||||
// The link is opened as a path rather than as an absolute url, which would leave the test's own
|
||||
// dev server for the platform's.
|
||||
// A path rather than an absolute url, which would leave the test's own dev server for the platform's.
|
||||
const link = new URL(await invite.inputValue())
|
||||
const carol = await as(users.carol, `/join${link.search}`)
|
||||
|
||||
|
|
@ -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(members.getByText("Dora Deckhand")).toBeVisible()
|
||||
|
||||
// Somebody who isn't in the space yet has to be let into it first. A pubkey pasted into the
|
||||
// search field selects that person outright, which is how erik is reachable without a profile.
|
||||
await admin.getByRole("button", {name: "Add members"}).click()
|
||||
await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey)
|
||||
await admin.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(admin.getByText("is not a member of this space. Add them?")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
|
||||
await expect(members.getByText(displayPubkey(erik.pubkey))).toBeVisible()
|
||||
|
||||
const doraCard = members.locator(".card").filter({hasText: "Dora Deckhand"})
|
||||
|
||||
await doraCard.getByRole("button").last().click()
|
||||
|
|
@ -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(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}) => {
|
||||
|
|
@ -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()
|
||||
|
||||
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 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()
|
||||
|
||||
// Her edit republishes with her original timestamp, and two messages sharing a second are
|
||||
// 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.
|
||||
// Her edit republishes with her original timestamp, and it changes the event id US-118 ties on.
|
||||
const sent = now()
|
||||
|
||||
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()
|
||||
|
||||
// Her own message offers zap, emoji, reply, edit and a menu; his offers no edit...
|
||||
await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5)
|
||||
await expect(messageActions(alice, "spelling?")).toHaveCount(4)
|
||||
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
|
||||
// His message offers her no delete
|
||||
await openMessageMenu(alice, "spelling?")
|
||||
|
||||
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(bob, "we made port")).toBeVisible()
|
||||
|
||||
const reaction = 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 react(carol, message(carol, "we made port").getByRole("button", {name: "Add a reaction"}))
|
||||
|
||||
await expect(reactionPill(carol, "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 expect(reactionPill(carol, "we made port")).not.toContainText("2")
|
||||
await expect(reactionPill(carol, "we made port")).not.toHaveClass(/button-primary/)
|
||||
await expect(reactionPill(bob, "we made port")).not.toContainText("2")
|
||||
|
||||
// On a phone, a pill she hasn't joined shows who reacted rather than adding her own
|
||||
|
|
@ -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.
|
||||
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 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
|
||||
// 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".
|
||||
// Exact, because the room behind the dialog names him too, as "@Bob Barnacle".
|
||||
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 admin.getByRole("button", {name: "Pin Message"}).click()
|
||||
|
||||
await expect(admin.getByText("Message pinned")).toBeVisible()
|
||||
|
||||
const banner = admin.locator(".room-pins")
|
||||
|
||||
await expect(banner).toContainText("muster at eight bells")
|
||||
|
|
@ -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 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)
|
||||
})
|
||||
|
||||
|
|
@ -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 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 expect(search.getByText("Last 24 Hours")).toBeVisible()
|
||||
await expect(search.getByText("Last 7 Days")).toBeVisible()
|
||||
await expect(search.getByText("Older")).toBeVisible()
|
||||
await expect(search).toContainText("harbor dredging starts monday")
|
||||
|
||||
await term.fill("mizzenmast")
|
||||
|
||||
await expect(page.getByText("No results found.")).toBeVisible()
|
||||
|
||||
await term.fill("harbor pilot")
|
||||
await search.getByText("harbor pilot is booked").click()
|
||||
|
||||
await expect(search).toHaveCount(0)
|
||||
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
|
||||
|
||||
// A permalink to one message lands on it. This room is three messages long, so the window that
|
||||
// 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.
|
||||
// This room is three messages long, so the window runs to the present and the newest loads too.
|
||||
await page.goto(`${path}?at=${older.event.created_at}`)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// A push notification links to the message it announced, which is near the newest end of the room,
|
||||
// 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.
|
||||
// A push notification links near the newest end, so the jump lands at the bottom with nothing below.
|
||||
test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// 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}) => {
|
||||
let shared!: SeededEvent
|
||||
|
||||
|
|
@ -920,7 +757,6 @@ test("US-028 share a message somewhere else", async ({seed, as}) => {
|
|||
expect(permalink.search).toBe(
|
||||
`?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: /^Share/}).click()
|
||||
|
|
@ -958,7 +794,7 @@ test("US-119 have a message read out loud", async ({seed, as}) => {
|
|||
|
||||
const {url} = scenario.space("space")
|
||||
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)
|
||||
|
||||
// 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()
|
||||
|
||||
// The quote, the mention and the url are each named rather than spelled out, since none of them
|
||||
// is intelligible read a character at a time.
|
||||
// The quote, the mention and the url are named rather than spelled out a character at a time.
|
||||
expect(spoken).toEqual([
|
||||
"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 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}) => {
|
||||
// The lightning address on bob's profile, and the lnurl endpoint it resolves to. Zapping only gets
|
||||
// as far as a dialog once dufflepud answers with a zapper for that endpoint.
|
||||
// Zapping only gets as far as a dialog once dufflepud answers with a zapper for that endpoint.
|
||||
const lud16 = "bob@zap.test"
|
||||
// A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of
|
||||
// its own even where, as here, nothing is ever paid.
|
||||
// A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of its own.
|
||||
const provider = makeTestUser("zapper")
|
||||
|
||||
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 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()`
|
||||
// installs, and before the navigation below, since a zapper is looked up once per page load.
|
||||
// Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs.
|
||||
await mockDufflepud(page.context(), {
|
||||
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")
|
||||
|
||||
await expect(page.locator(".dialog-overlay[inert]")).toHaveCount(1)
|
||||
await connect.getByRole("button", {name: "Connect with WebLN"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!")
|
||||
await expect(connect).toHaveCount(0)
|
||||
|
||||
// The zap dialog was underneath rather than replaced, so it answers to the wallet she now has
|
||||
// instead of still asking for one, and the amount she had typed survived the detour.
|
||||
// The zap dialog was underneath rather than replaced, so the amount she had typed survived.
|
||||
await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0)
|
||||
await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible()
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import {
|
|||
users,
|
||||
} from "../harness"
|
||||
|
||||
// Every page in a space renders one PageContent, so the number added to the document is the number
|
||||
// of times the page was built.
|
||||
// Every page in a space renders one PageContent, so the count is how often the page was built.
|
||||
const watchPageBuilds = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
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.getByRole("link", {name: "Space Lounge"})).toBeVisible()
|
||||
|
||||
// Alice belongs to both spaces, so the client is talking to the other relay at the same time —
|
||||
// its room and its messages just don't belong in this one. It syncs independently, so wait until
|
||||
// both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that
|
||||
// has not loaded yet, which would pass against a client that does conflate them.
|
||||
// toHaveCount(0) is equally satisfied by an element that has not loaded yet, so wait for both.
|
||||
await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true)
|
||||
await expect
|
||||
.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 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
|
||||
// used to replace that room's entry rather than push one, so the second step overshot it.
|
||||
// Two entries back: the other space's landing page, then the room this started on.
|
||||
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 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
|
||||
// rather than push. One step back is the room this started on, not the one it never left.
|
||||
// The space's entry page is the room you are on, so this navigates nowhere and replaces.
|
||||
await page.locator('.primary-nav [data-tip^="space"]').click()
|
||||
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(drawer).toHaveCount(0)
|
||||
|
||||
// The menu gave its entry back and the room pushed its own, so one step back is the room the
|
||||
// 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.
|
||||
// The content is asserted as well as the url, since a back that only rewrites the url would pass.
|
||||
await page.goBack()
|
||||
|
||||
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()
|
||||
|
||||
// The rail is in the menu on a phone, so a space is picked with the menu still open and the room
|
||||
// is picked after it. Navigating out from under the menu would close it on the first tap.
|
||||
// The rail is in the menu on a phone, and navigating out from under it would close it.
|
||||
await drawer.locator('.primary-nav [data-tip^="other"]').click()
|
||||
|
||||
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(drawer).toHaveCount(0)
|
||||
|
||||
// The space she started in has a page behind it now, so picking it needs no second tap and the
|
||||
// menu has nothing left to ask.
|
||||
// The space she started in has a page behind it now, so picking it needs no second tap.
|
||||
await page.getByRole("button", {name: "Open space menu"}).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")
|
||||
|
||||
// A relay whose document claims no nip-29. The entry path used to read that as "open the chat
|
||||
// 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.
|
||||
// A relay whose document claims no nip-29. The document arrives after the first navigation.
|
||||
const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}}
|
||||
const page = await as(users.alice, spacePath(space.url), {relayInfo})
|
||||
|
||||
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}) => {
|
||||
|
|
@ -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"}),
|
||||
).toBeVisible()
|
||||
|
||||
// The rebuild this guards against landed 20ms after the first build, which is before the article
|
||||
// itself is on screen on a slower run.
|
||||
// The rebuild this guards against landed 20ms after the first build.
|
||||
await page.waitForTimeout(250)
|
||||
await expectPageBuilds(page, 1)
|
||||
|
||||
// Another article is the same route with different params, which SvelteKit answers by keeping the
|
||||
// page it has. The page reads the address it renders once, so this one does have to be rebuilt.
|
||||
// Another article is the same route with different params, which SvelteKit answers by keeping the page.
|
||||
await page.getByRole("link", {name: "Repotting in Winter"}).click()
|
||||
|
||||
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()
|
||||
|
||||
// The whole message is a button too, and its accessible name carries the author's, so the
|
||||
// name on its own is the one that opens the profile.
|
||||
// The whole message is a button too, and its accessible name carries the author's.
|
||||
await message(page, "anyone seen the anchor")
|
||||
.getByRole("button", {name: "Bob Barnacle", exact: true})
|
||||
.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 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
|
||||
// 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.
|
||||
// The modal gave its entry back before the profile page pushed its own, so one step back is the room.
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
type Seeded = {readonly id: string}
|
||||
|
||||
// The relay picker, which has no heading of its own — and is pushed over the list modal rather
|
||||
// than alongside it, so it is the only dialog in the dom while it is open.
|
||||
// The relay picker is pushed over the list modal, so it is the only dialog in the dom.
|
||||
const relayPicker = (page: Page) => topDialog(page)
|
||||
|
||||
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
|
||||
// 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.
|
||||
// A toast clears after five seconds, which outlasts the batch a saved setting reaches disk in.
|
||||
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
|
||||
// around both is what a slider is named from.
|
||||
// A Field lays its slider out under the row holding the label and the current value.
|
||||
const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"})
|
||||
|
||||
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}) => {
|
||||
const space = relay("space")
|
||||
|
||||
// A second relay for the picker to offer. Nobody is a member of it — what puts a relay in the
|
||||
// suggestion pool is its nip-11 document having been fetched, and every scenario relay is an
|
||||
// indexer, so bob's client reads this one at startup.
|
||||
// A relay is offered as a suggestion once its nip-11 document has been fetched.
|
||||
relay("other")
|
||||
|
||||
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 blocked = page.getByRole("button", {name: /Blocked$/})
|
||||
|
||||
await expect(blocked).toContainText("0 Blocked")
|
||||
|
||||
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 relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).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")
|
||||
|
||||
// A blocked relay is one bob never wants used, so it stops being offered as a suggestion. It was
|
||||
// the picker's only offer a moment ago, which is what makes its absence about the block.
|
||||
// It was the picker's only offer a moment ago, which is what makes its absence about the block.
|
||||
await blocked.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 sound = settingToggle(page, "Play sound for new activity")
|
||||
const push = settingToggle(page, "Enable push notifications")
|
||||
const sound = settingToggle(page, "Play sound for new messages")
|
||||
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
|
||||
|
||||
await expect(sound).toBeChecked()
|
||||
await expect(push).not.toBeChecked()
|
||||
await expect(alertTypes).not.toHaveClass(/opacity-50/)
|
||||
|
||||
// With nothing left to be alerted through, there is nothing to be alerted about.
|
||||
await sound.uncheck()
|
||||
const reminders = alertTypes.locator("div.items-center.gap-2:has(input[type=number])")
|
||||
|
||||
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()
|
||||
|
||||
await expect(sound).toBeChecked()
|
||||
await expect(alertTypes).not.toHaveClass(/opacity-50/)
|
||||
|
||||
// Push asks the browser for permission, and this context was never granted it.
|
||||
await push.check()
|
||||
// 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 reminders.nth(1).locator("input").fill("0")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Failed to request notification permissions")
|
||||
await expect(push).not.toBeChecked()
|
||||
await expect(sound).toBeChecked()
|
||||
await expect(page.getByRole("alert")).toContainText("at least a minute")
|
||||
|
||||
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 page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
|
|
@ -114,12 +98,13 @@ test("US-086 configure alerts", async ({seed, as}) => {
|
|||
await waitForToastToClear(page)
|
||||
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}) => {
|
||||
let picture!: Seeded
|
||||
let link!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
|
@ -129,7 +114,6 @@ test("US-087 configure content display", async ({seed, as}) => {
|
|||
space.join(user.bob, "general")
|
||||
|
||||
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE))
|
||||
link = space.message(user.bob, "general", "https://example.test/announcement", at(35, MINUTE))
|
||||
|
||||
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 showMedia = settingToggle(page, "Show media?")
|
||||
|
||||
await expect(hideSensitive).toBeChecked()
|
||||
await expect(showMedia).toBeChecked()
|
||||
|
||||
await hideSensitive.uncheck()
|
||||
await showMedia.uncheck()
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
|
@ -153,15 +134,10 @@ test("US-087 configure content display", async ({seed, as}) => {
|
|||
await page.goto(roomPath(url, "general"))
|
||||
|
||||
const pictureMessage = page.locator(`[data-event="${picture.id}"]`)
|
||||
const linkMessage = page.locator(`[data-event="${link.id}"]`)
|
||||
|
||||
await expect(pictureMessage.getByRole("link", {name: "images.test/sunset.png"})).toBeVisible()
|
||||
await expect(pictureMessage.locator('img[src="https://images.test/sunset.png"]')).toHaveCount(0)
|
||||
|
||||
await expect(linkMessage.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
|
||||
await expect(linkMessage.locator(".spinner")).toHaveCount(0)
|
||||
await expect(linkMessage.getByText("Unable to load a preview")).toHaveCount(0)
|
||||
|
||||
await page.goto("/settings/content")
|
||||
|
||||
await expect(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")
|
||||
|
||||
// The send delay is the only slider on this page.
|
||||
const sendDelay = page.locator('input[type="range"]')
|
||||
const servers = page.getByRole("listitem")
|
||||
|
||||
await expect(page.getByText("Delay sending 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.getByLabel("Send delay").fill("3000")
|
||||
|
||||
await page.getByRole("button", {name: "Add Server"}).click()
|
||||
|
||||
await expect(servers).toHaveCount(1)
|
||||
|
||||
await servers.first().locator("input").fill("https://media.test/")
|
||||
|
||||
await page.getByRole("button", {name: "Add Server"}).click()
|
||||
|
||||
await expect(servers).toHaveCount(2)
|
||||
|
||||
// Each row leads with its own remove button; the drag handle is the one with a label.
|
||||
await servers.nth(1).getByRole("button").first().click()
|
||||
|
||||
await expect(servers).toHaveCount(1)
|
||||
await expect(servers.first().locator("input")).toHaveValue("https://media.test/")
|
||||
await page.getByRole("listitem").first().locator("input").fill("https://media.test/")
|
||||
|
||||
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 page.reload()
|
||||
|
||||
await expect(page.getByText("Delay sending messages and comments for 3 seconds.")).toBeVisible()
|
||||
await expect(page.locator('input[type="range"]')).toHaveValue("3000")
|
||||
await expect(page.getByLabel("Send delay")).toHaveValue("3000")
|
||||
})
|
||||
|
||||
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 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 usage.uncheck()
|
||||
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 pow = requestSliders(page).first()
|
||||
const wot = requestSliders(page).last()
|
||||
|
||||
await expect(pow).toHaveValue("16")
|
||||
await expect(wot).toHaveValue("3")
|
||||
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 requestSliders(page).first().fill("24")
|
||||
await requestSliders(page).last().fill("1")
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 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 expect(body).toHaveAttribute("data-theme", "dark")
|
||||
await expect.poll(colorScheme).toBe("dark")
|
||||
|
||||
await page.getByRole("button", {name: "System", exact: true}).click()
|
||||
|
||||
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.
|
||||
await page.emulateMedia({colorScheme: "dark"})
|
||||
|
||||
await expect(body).toHaveAttribute("data-theme", "dark")
|
||||
await expect.poll(colorScheme).toBe("dark")
|
||||
|
||||
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 expect(body).toHaveAttribute("data-fl-theme", "navy")
|
||||
await expect(page.getByLabel("Style")).toHaveValue("navy")
|
||||
|
||||
// Font size is the only slider on this page, and the only setting here that is published.
|
||||
const fontSize = page.locator('input[type="range"]')
|
||||
// Font size is a setting, so it goes out with the rest of the page.
|
||||
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("button", {name: "Save Changes"})).toHaveCount(0)
|
||||
|
||||
await fontSize.fill("1.25")
|
||||
|
||||
await expect(page.getByText("125%")).toBeVisible()
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
|
||||
|
||||
// Moving the slider is the save, so the size the document is rendered at survives a reload
|
||||
// without anything else having been pressed.
|
||||
await waitForToastToClear(page)
|
||||
await page.reload()
|
||||
|
||||
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.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
// The dialog closes itself once the profile has gone out, and the page behind it carries its own
|
||||
// "Save Changes" — so wait it out rather than leaving that name ambiguous.
|
||||
// The dialog closes itself once the profile has gone out, and the page behind it has its own Save.
|
||||
await expect(address).toHaveCount(0)
|
||||
await expect(page.getByText("alice@example.test")).toBeVisible()
|
||||
await expect(page.getByText("Not set")).toHaveCount(0)
|
||||
|
||||
await page.getByRole("button", {name: "Update"}).click()
|
||||
await address.getByPlaceholder("user@domain.com").fill("")
|
||||
await address.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(address).toHaveCount(0)
|
||||
await expect(page.getByText("Not set")).toBeVisible()
|
||||
|
||||
// Each preset is a row 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()
|
||||
// The same utility classes land on other rows, so this is pinned to the zap form's input rows.
|
||||
const presets = page
|
||||
.locator("form")
|
||||
.filter({hasText: "Zap Amounts"})
|
||||
.locator("div.items-center.gap-2:has(input)")
|
||||
|
||||
await presets.first().locator("input").fill("500")
|
||||
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 page.reload()
|
||||
|
||||
const saved = page
|
||||
.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")
|
||||
await expect(presets.first().locator("input")).toHaveValue("500")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,22 +15,12 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
|
|||
|
||||
const drawer = page.locator(".drawer")
|
||||
|
||||
await expect(drawer).toHaveCount(0)
|
||||
|
||||
await page.getByRole("button", {name: "Open space menu"}).click()
|
||||
|
||||
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.
|
||||
// Sized to the panel instead, it runs off the right of the screen.
|
||||
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()
|
||||
// The drawer stops short of the bottom bar, and playwright's hit-target check is what proves it.
|
||||
await page.getByRole("button", {name: "Close space menu"}).click()
|
||||
|
||||
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)
|
||||
|
||||
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page
|
||||
// that is in no space at all, on the last space the reader was in.
|
||||
// The button is the bottom bar's rather than the page's, so it opens on the last space visited.
|
||||
await page.goto("/chat")
|
||||
|
||||
await page.getByRole("button", {name: "Open space menu"}).click()
|
||||
|
|
|
|||
|
|
@ -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 type {Page} from "@playwright/test"
|
||||
import type {SeededSpace} 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
|
||||
// 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.
|
||||
// Room lists reach indexeddb in three-second batches, so a spec about a reload waits on this first.
|
||||
const cachedFirstSpace = async (page: Page, pubkey: string) => {
|
||||
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
|
||||
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]
|
||||
}
|
||||
|
||||
// The rail shows icons and no text, so a row is read by the tooltip naming its relay. See
|
||||
// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name.
|
||||
// The rail shows icons and no text, so a row is read by the tooltip naming its relay.
|
||||
const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]")
|
||||
|
||||
const expectRailFirst = (page: Page, name: string) =>
|
||||
|
|
@ -24,11 +21,7 @@ const expectRailFirst = (page: Page, name: string) =>
|
|||
new RegExp(`^${name}`),
|
||||
)
|
||||
|
||||
// A drop reorders the list in place and publishes a new room list behind it, so the order on screen
|
||||
// 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.
|
||||
// A drop reorders the list in place and publishes behind it, so the copy on disk is the round trip.
|
||||
const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
|
||||
await expectRailFirst(page, space.name)
|
||||
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 unsigned = scenario.space("unsigned")
|
||||
|
||||
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it
|
||||
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
|
||||
// The spaces page discovers unjoined spaces by pulling the room lists of its bootstrap pubkeys.
|
||||
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.
|
||||
await expect(page.getByText("Browse Spaces")).toBeVisible()
|
||||
// The page is for spaces she hasn't joined. The ones she has are in the rail.
|
||||
await expect(page.getByText(unsigned.url)).toBeVisible()
|
||||
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...")
|
||||
|
||||
await term.fill("unsigned")
|
||||
|
||||
await expect(page.getByText(unsigned.url)).toBeVisible()
|
||||
|
||||
await term.fill("nothing by that name")
|
||||
|
||||
await expect(page.getByText(unsigned.url)).toHaveCount(0)
|
||||
|
||||
await term.fill("")
|
||||
|
||||
// A space she hasn't joined asks her to join first
|
||||
await term.fill("unsigned")
|
||||
await page.getByRole("button").filter({hasText: unsigned.url}).click()
|
||||
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
|
||||
|
||||
// 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
|
||||
await page.goto("/spaces")
|
||||
|
||||
await expectRailFirst(page, space.name)
|
||||
|
||||
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag
|
||||
// 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.
|
||||
// Chromium's synthetic drag starts and moves it but never delivers the drop the reorder needs.
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
|
||||
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))
|
||||
|
||||
// Arriving at a space she hasn't joined raises the join prompt on its own
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
||||
await expect(page.getByText(other.url)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
// Going back left her un-joined, so arriving again asks again
|
||||
await page.goto(spacePath(other.url))
|
||||
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
||||
|
||||
await page.goto("/spaces")
|
||||
await page.getByRole("button", {name: "Add Space"}).click()
|
||||
await page.getByRole("button", {name: /Join a space/}).click()
|
||||
|
||||
const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox")
|
||||
// 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 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 page = await as(users.alice, "/")
|
||||
|
||||
// A space's own path redirects to the page it opens on, and a modal raised while that navigation
|
||||
// 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.
|
||||
// A space's own path redirects, and a modal raised while that navigation is in flight is lost.
|
||||
let held = 0
|
||||
|
||||
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).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.
|
||||
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 space = relay("space")
|
||||
|
||||
// This relay refuses a join that carries no claim. A claim is not an event a scenario can
|
||||
// publish — the relay only registers one through the nip-86 method the invite dialog calls —
|
||||
// so the code alice needs comes out of that dialog below.
|
||||
// The relay only registers a claim through the nip-86 method the invite dialog calls.
|
||||
closed.room("lobby", {name: "Lobby"})
|
||||
|
||||
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 space = scenario.space("space")
|
||||
|
||||
// Administering a relay is not the same as belonging to it, so the space asks admin to join
|
||||
// like anyone else — and issues him an invite code all the same.
|
||||
// Administering a relay is not the same as belonging to it.
|
||||
const admin = await as(users.admin, spacePath(closed.url) + "/about")
|
||||
|
||||
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))
|
||||
|
||||
// Turned away rather than joined, so the prompt becomes a request
|
||||
await expect(alice.getByRole("button", {name: "Request Access"})).toBeEnabled()
|
||||
|
||||
await alice.getByRole("button", {name: "Request Access"}).click()
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox")
|
||||
|
||||
await code.fill("NOTTHECODE")
|
||||
|
|
@ -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
|
||||
await expect(alice.getByRole("alert")).toContainText("join request rejected")
|
||||
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
await code.fill(claim)
|
||||
await alice.getByRole("button", {name: "Join Space"}).click()
|
||||
|
|
@ -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 admin.getByRole("button", {name: "Ban member"}).click()
|
||||
|
||||
await expect(admin.getByText("Ban @Bob Barnacle from the space?")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByText("Member has successfully been banned!")).toBeVisible()
|
||||
|
|
@ -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))
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible()
|
||||
await expect(bob.getByText(/not a member of this relay/i)).toBeVisible()
|
||||
|
||||
// From here he can ask for a code back in
|
||||
await bob.getByRole("button", {name: "Request Access"}).click()
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
// ...or give up on the space, which takes it off his list
|
||||
await bob.getByRole("button", {name: "Leave Space"}).click()
|
||||
|
||||
await expect(bob).toHaveURL(/\/home/)
|
||||
|
||||
await bob.goto("/spaces")
|
||||
|
||||
await expect(railSpaces(bob)).toHaveCount(0)
|
||||
})
|
||||
|
||||
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/)
|
||||
|
||||
// In-app rather than a fresh load: leaving the space is published in the background, and a
|
||||
// page that reloads before it reaches disk reads the list he had a moment ago.
|
||||
// In-app rather than a fresh load: leaving is published in the background and reaches disk late.
|
||||
await bob.getByRole("link", {name: "All Spaces"}).click()
|
||||
|
||||
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 alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo})
|
||||
const movedDialog = alice.locator("form")
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "This space has moved"})).toBeVisible()
|
||||
await expect(movedDialog.getByText("space.test", {exact: true})).toBeVisible()
|
||||
await expect(movedDialog.getByText("other.test", {exact: true})).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: "Update and go"}).click()
|
||||
|
||||
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
|
||||
|
||||
// In-app rather than a fresh load: the updated list is published in the background, and a page
|
||||
// that reloads before it reaches disk reads the old address back.
|
||||
// In-app rather than a fresh load: the updated list is published in the background.
|
||||
await alice.getByRole("link", {name: "All Spaces"}).click()
|
||||
|
||||
await expect(railSpaces(alice)).toHaveCount(1)
|
||||
await expectRailFirst(alice, other.name)
|
||||
|
||||
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 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}) => {
|
||||
|
|
@ -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: "Leave Space"}).click()
|
||||
|
||||
await expect(page.getByText("Are you sure you want to leave?")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/home/)
|
||||
|
||||
// In-app rather than a fresh load: leaving is published in the background, and a page that
|
||||
// reloads before it reaches disk reads the list he had a moment ago.
|
||||
// In-app rather than a fresh load: leaving is published in the background.
|
||||
await page.getByRole("link", {name: "All Spaces"}).click()
|
||||
|
||||
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}) => {
|
||||
|
|
@ -446,38 +349,20 @@ test("US-015 view a space's details", async ({seed, as}) => {
|
|||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish
|
||||
// on its own, so the scenario merges them over the relay's real document.
|
||||
// Contact and terms are nip-11 fields zooid doesn't publish.
|
||||
const relayInfo = {
|
||||
[space.url]: {
|
||||
icon: "https://space.test/icon.png",
|
||||
contact: "harbormaster@space.test",
|
||||
terms_of_service: "https://space.test/terms",
|
||||
privacy_policy: "https://space.test/privacy",
|
||||
limitation: {auth_required: true, payment_required: true, min_pow_difficulty: 20},
|
||||
},
|
||||
}
|
||||
|
||||
const carol = await as(users.carol, spacePath(space.url) + "/about", {relayInfo})
|
||||
|
||||
await expect(carol.locator('img[src="https://space.test/icon.png"]').first()).toBeVisible()
|
||||
await expect(carol.getByRole("heading", {name: "space"})).toBeVisible()
|
||||
await expect(carol.getByText("space.test", {exact: true}).first()).toBeVisible()
|
||||
await expect(carol.getByText(/Throwaway relay/)).toBeVisible()
|
||||
await expect(carol.getByText(/harbormaster@space\.test/)).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 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
|
||||
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
|
||||
await expect(carol.getByText("Start with the harbor rules")).toBeVisible()
|
||||
|
||||
await carol.getByRole("link", {name: "View all members"}).click()
|
||||
|
||||
await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/)
|
||||
})
|
||||
|
||||
test("US-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.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR))
|
||||
space.message(user.alice, "random", "kraken jokes only in here", at(3, DAY))
|
||||
space.message(user.alice, "general", "ancient kraken lore, volume one", at(3, WEEK))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// The directory rather than a feed: search is reachable from every page in a space, and this is
|
||||
// one where a result's own text can't also be on the page behind the dialog.
|
||||
// The directory rather than a feed, since a result's own text can't be on the page behind it.
|
||||
const page = await as(users.alice, spacePath(space.url) + "/directory")
|
||||
|
||||
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 expect(page.getByText("Last 24 Hours")).toBeVisible()
|
||||
await expect(page.getByText("Last 7 Days")).toBeVisible()
|
||||
await expect(page.getByText("Older")).toBeVisible()
|
||||
|
||||
await expect(page.getByText("the kraken surfaced at dawn")).toBeVisible()
|
||||
await expect(page.getByText("ancient kraken lore, volume one")).toBeVisible()
|
||||
|
||||
const fromRandom = page.getByRole("button").filter({hasText: "kraken jokes only in here"})
|
||||
|
||||
await expect(fromRandom).toContainText("Random")
|
||||
|
||||
await fromRandom.click()
|
||||
await page.getByRole("button").filter({hasText: "kraken jokes only in here"}).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -133,12 +133,12 @@ createCapacitorElectronApp({
|
|||
csp: {
|
||||
// SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap.
|
||||
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: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"img-src 'self' data: blob: https://dufflepud.coracle.social",
|
||||
"font-src 'self' data:",
|
||||
"media-src 'self' blob:",
|
||||
"connect-src 'self' https: wss: ws: http:",
|
||||
|
|
|
|||
9
fastlane/metadata/android/en-US/changelogs/54.txt
Normal file
9
fastlane/metadata/android/en-US/changelogs/54.txt
Normal 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
|
||||
6
fastlane/metadata/android/en-US/changelogs/55.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/55.txt
Normal 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
|
||||
3
fastlane/metadata/android/en-US/full_description.txt
Normal file
3
fastlane/metadata/android/en-US/full_description.txt
Normal 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.
|
||||
BIN
fastlane/metadata/android/en-US/images/featureGraphic.png
Normal file
BIN
fastlane/metadata/android/en-US/images/featureGraphic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Digital communities for free people
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Flotilla
|
||||
|
|
@ -21,8 +21,8 @@ with the Node, pnpm, Java, Android SDK, and Gradle versions pinned by this repos
|
|||
|
||||
```sh
|
||||
corepack enable
|
||||
./scripts/prepare-fdroid-source.sh
|
||||
./scripts/build-fdroid-assets.sh
|
||||
./scripts/fdroid/prepare.sh
|
||||
./scripts/fdroid/build.sh
|
||||
cd android
|
||||
./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,
|
||||
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
|
||||
subdir: android/app
|
||||
gradle:
|
||||
- fdroid
|
||||
prebuild:
|
||||
- ../../scripts/prepare-fdroid-source.sh
|
||||
build:
|
||||
- ../../scripts/build-fdroid-assets.sh
|
||||
```
|
||||
## Reproducible builds
|
||||
|
||||
F-Droid rebuilds each tag, downloads the apk at the recipe's `Binaries` url, and ships that apk
|
||||
instead of its own when copying its signature onto F-Droid's build verifies. So the F-Droid app
|
||||
carries the distribution key, the one `AllowedAPKSigningKeys` names, and can never switch to
|
||||
F-Droid's key.
|
||||
|
||||
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
|
||||
|
||||
Stable releases use bare version tags such as `1.9.1`. Proposed update metadata:
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
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
|
||||
`android/app/build.gradle` at that tag.
|
||||
|
||||
The initial `fdroiddata` submission must still review scanner exceptions for FLOSS
|
||||
build tools, optional OpenRouter use for a possible `NonFreeNet` declaration, and
|
||||
the final F-Droid signing certificate for Android App Links.
|
||||
build tools and optional OpenRouter use for a possible `NonFreeNet` declaration.
|
||||
|
|
|
|||
67
fdroid/metadata/social.flotilla.fdroid.yml
Normal file
67
fdroid/metadata/social.flotilla.fdroid.yml
Normal 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
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
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 */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
A1F0C0DE2E90000200005CE2 /* SceneDelegate.swift */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
|
|
@ -251,7 +251,6 @@
|
|||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -308,6 +307,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
A1F0C0DE2E90000100005CE1 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -322,14 +322,6 @@
|
|||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
|
@ -350,7 +342,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -362,7 +354,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.11.0;
|
||||
MARKETING_VERSION = 1.11.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
|
||||
|
|
@ -387,7 +379,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -399,7 +391,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.11.0;
|
||||
MARKETING_VERSION = 1.11.2;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -541,7 +533,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
|
|
@ -550,7 +542,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.11.0;
|
||||
MARKETING_VERSION = 1.11.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -570,7 +562,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
|
|
@ -579,7 +571,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.11.0;
|
||||
MARKETING_VERSION = 1.11.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -1,88 +1,21 @@
|
|||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||
completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
guard let url = shortcutURL(for: shortcutItem.type) else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -80,11 +80,26 @@
|
|||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string></string>
|
||||
|
|
|
|||
58
ios/App/App/SceneDelegate.swift
Normal file
58
ios/App/App/SceneDelegate.swift
Normal 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: [:])
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
use_frameworks!
|
||||
|
|
@ -9,18 +9,18 @@ use_frameworks!
|
|||
install! 'cocoapods', :disable_input_output_paths => true
|
||||
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :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.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.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 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.4/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 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.4/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 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/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 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.4/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 '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 '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.5.2/node_modules/@capacitor/clipboard'
|
||||
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.5_@capacitor+core@8.5.2/node_modules/@capacitor/keyboard'
|
||||
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.2_@capacitor+core@8.5.2/node_modules/@capacitor/push-notifications'
|
||||
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.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_46ef56d5c94707ead7edfcb859900e71/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
|
|
|||
68
package.json
68
package.json
|
|
@ -1,36 +1,41 @@
|
|||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.2",
|
||||
"author": {
|
||||
"name": "Jon Staab"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./scripts/build.sh",
|
||||
"build:desktop": "bash scripts/build-desktop.sh",
|
||||
"dev:desktop": "node scripts/dev-desktop.mjs",
|
||||
"build": "./scripts/build/app.sh",
|
||||
"build:desktop": "bash scripts/desktop/build.sh",
|
||||
"dev:desktop": "node scripts/desktop/dev.mjs",
|
||||
"start:desktop": "npm --prefix electron start",
|
||||
"build:server": "vite build --config vite.config.server.ts",
|
||||
"start": "node server.js",
|
||||
"release": "node scripts/release.mjs",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"release:check": "node scripts/release/local.mjs --check && node scripts/release/ci.mjs --check",
|
||||
"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: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",
|
||||
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
|
||||
"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": "playwright test",
|
||||
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
|
||||
"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:all": "prettier --write src",
|
||||
"prepare": "husky",
|
||||
"package:desktop:linux": "node scripts/package-desktop.mjs linux",
|
||||
"package:desktop:windows": "node scripts/package-desktop.mjs windows",
|
||||
"package:desktop:macos": "node scripts/package-desktop.mjs macos"
|
||||
"package:desktop:linux": "node scripts/desktop/package.mjs linux",
|
||||
"package:desktop:windows": "node scripts/desktop/package.mjs windows",
|
||||
"package:desktop:macos": "node scripts/desktop/package.mjs macos"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
|
|
@ -61,17 +66,16 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.1",
|
||||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/clipboard": "^8.0.1",
|
||||
"@capacitor/core": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.0",
|
||||
"@capacitor/ios": "^8.0.1",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@capacitor/filesystem": "^8.1.3",
|
||||
"@capacitor/ios": "^8.5.2",
|
||||
"@capacitor/keyboard": "^8.0.5",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/push-notifications": "^8.1.2",
|
||||
"@capacitor/share": "^8.0.2",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||
"@capawesome/capacitor-badge": "^8.0.0",
|
||||
"@capawesome/capacitor-electron": "0.1.1",
|
||||
|
|
@ -89,20 +93,21 @@
|
|||
"@tiptap/core": "^2.27.3",
|
||||
"@tiptap/pm": "^2.27.3",
|
||||
"@tiptap/suggestion": "^2.27.3",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@welshman/app": "^0.11.0",
|
||||
"@welshman/content": "^0.11.0",
|
||||
"@welshman/domain": "^0.11.0",
|
||||
"@welshman/editor": "^0.11.0",
|
||||
"@welshman/feeds": "^0.11.0",
|
||||
"@welshman/lib": "^0.11.0",
|
||||
"@welshman/net": "^0.11.0",
|
||||
"@welshman/signer": "^0.11.0",
|
||||
"@welshman/store": "^0.11.0",
|
||||
"@welshman/util": "^0.11.0",
|
||||
"@welshman/app": "^0.12.3",
|
||||
"@welshman/content": "^0.12.3",
|
||||
"@welshman/domain": "^0.12.3",
|
||||
"@welshman/editor": "^0.12.3",
|
||||
"@welshman/feeds": "^0.12.3",
|
||||
"@welshman/lib": "^0.12.3",
|
||||
"@welshman/net": "^0.12.3",
|
||||
"@welshman/signer": "^0.12.3",
|
||||
"@welshman/store": "^0.12.3",
|
||||
"@welshman/util": "^0.12.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"dompurify": "^3.4.13",
|
||||
|
|
@ -114,6 +119,7 @@
|
|||
"hono": "^4.12.23",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"livekit-client": "^2.17.2",
|
||||
"marked": "^18.0.9",
|
||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
source ./scripts/build-web.sh
|
||||
source ./scripts/build/web.sh
|
||||
|
||||
npx cap sync
|
||||
npx @capacitor/assets generate \
|
||||
|
|
@ -10,4 +10,4 @@ npx @capacitor/assets generate \
|
|||
--splashBackgroundColorDark '#191E24'
|
||||
|
||||
# @capacitor/assets doesn't generate Android notification icons
|
||||
node scripts/generate-notification-icon.mjs
|
||||
node scripts/build/notification-icon.mjs
|
||||
|
|
@ -4,7 +4,7 @@ set -e
|
|||
export FLOTILLA_DESKTOP=1
|
||||
export NODE_ENV=production
|
||||
unset FLOTILLA_DESKTOP_DEV_URL CAPACITOR_ELECTRON_DEV_SERVER_URL
|
||||
source ./scripts/build-web.sh
|
||||
source ./scripts/build/web.sh
|
||||
|
||||
export VITE_PLATFORM_NAME
|
||||
# Capacitor sync swallows copy failures; its rejection handler also leaves exit 0.
|
||||
|
|
@ -3,7 +3,7 @@ import {createRequire} from "node:module"
|
|||
import {dirname, resolve} from "node:path"
|
||||
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 windows = process.platform === "win32"
|
||||
let server
|
||||
|
|
@ -5,10 +5,14 @@ import {fileURLToPath} from "node:url"
|
|||
import sharp from "sharp"
|
||||
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 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 = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -25,7 +29,7 @@ const run = (command, args, options = {}) =>
|
|||
|
||||
try {
|
||||
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.CAPACITOR_ELECTRON_DEV_SERVER_URL
|
||||
|
|
@ -44,7 +48,7 @@ try {
|
|||
env.VITE_PLATFORM_LOGO = "static/desktop-logo.png"
|
||||
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, "static/favicon.ico"), join(root, "electron/generated/icon.ico"))
|
||||
for (const [size, name] of [
|
||||
|
|
@ -70,15 +74,18 @@ try {
|
|||
// --dir replaces configured targets, including their architectures.
|
||||
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})
|
||||
if (files.some(file => file.endsWith(".node"))) {
|
||||
throw new Error(
|
||||
"Native Electron addons require a Windows ABI rebuild before packaging on Windows",
|
||||
)
|
||||
throw new Error(`Native Electron addons require a rebuild before packaging for ${target}`)
|
||||
}
|
||||
await mkdir(join(root, "electron/dist"), {recursive: true})
|
||||
const directory = await mkdtemp(join(root, "electron/dist/package-"))
|
||||
const docker = env.DOCKER || "docker"
|
||||
const container = `flotilla-package-${process.pid}`
|
||||
try {
|
||||
await cp(join(root, "package.json"), join(directory, "package.json"))
|
||||
for (const file of [
|
||||
|
|
@ -92,13 +99,14 @@ try {
|
|||
]) {
|
||||
await cp(join(root, "electron", file), join(directory, "electron", file), {recursive: true})
|
||||
}
|
||||
await run("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
// Copied in and out rather than mounted: a CI job that shares the host's docker socket
|
||||
// would mount the host's path, not its own
|
||||
await run(docker, [
|
||||
"create",
|
||||
"--name",
|
||||
container,
|
||||
"--platform",
|
||||
"linux/amd64",
|
||||
"--volume",
|
||||
`${directory}:/project:Z`,
|
||||
"--workdir",
|
||||
"/project/electron",
|
||||
"--env",
|
||||
|
|
@ -108,14 +116,19 @@ try {
|
|||
"electronuserland/builder@sha256:41ae540902461b6cbc988987db79547fcc10cda04d2a6c6367504f59d4b37c64",
|
||||
"bash",
|
||||
"-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,
|
||||
])
|
||||
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 {
|
||||
await run(docker, ["rm", "--force", container], {stdio: "ignore"}).catch(() => {})
|
||||
await rm(directory, {recursive: true, force: true})
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
cd "$root"
|
||||
[[ -f .fdroid/prepared ]]
|
||||
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 \
|
||||
.fdroid/signer/src/index.ts
|
||||
export VITE_BUILD_HASH=$(cat .fdroid/build-hash)
|
||||
source scripts/build.sh
|
||||
source scripts/build/app.sh
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.."
|
||||
if [[ -f .fdroid/prepared ]]; then
|
||||
exit 0
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue