Add some skill files
This commit is contained in:
parent
40889a91cf
commit
75451b71e3
6 changed files with 1681 additions and 0 deletions
341
.agents/skills/flotilla-architecture/SKILL.md
Normal file
341
.agents/skills/flotilla-architecture/SKILL.md
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
---
|
||||
name: flotilla-architecture
|
||||
description: "Use this skill when deciding where new code belongs in flotilla: which layer (routes, app/components, app, lib) or welshman package owns it, which src/app module to extend, or how a kind-based space feature is laid out across the layers. Also use it for the layer and import rules, path aliases, the boot sequence, platform-specific behavior (Capacitor, Android, iOS, Electron, PWA), the link-preview server, env and branding variables, the e2e harness, and the lint/check tooling."
|
||||
---
|
||||
|
||||
# Flotilla architecture
|
||||
|
||||
Flotilla is a client-only SvelteKit app, built with `adapter-static` and an `index.html` fallback,
|
||||
with `ssr = false` in `src/routes/+layout.ts`. The same build ships as a web app/PWA, inside
|
||||
Capacitor for Android and iOS, and inside Electron for desktop. Welshman does almost all of the
|
||||
nostr work. Flotilla's own code is app policy (what to sync, when to authenticate, what to show)
|
||||
and UI.
|
||||
|
||||
## The layers
|
||||
|
||||
| Layer | Path | Import as | May import |
|
||||
|---|---|---|---|
|
||||
| Routes | `src/routes` | — | anything |
|
||||
| App components | `src/app/components` | `@app/components/X.svelte` | `@app`, `@lib` |
|
||||
| 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
|
||||
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`.
|
||||
|
||||
SvelteKit's `$app/*` (`$app/navigation`, `$app/state`, `$app/stores`) is an external dependency,
|
||||
unrelated to flotilla's `@app/*`. App modules use it freely, for example `modal.ts`, `routes.ts`
|
||||
and `sync.ts`.
|
||||
|
||||
### Why the graph is one-way
|
||||
|
||||
- `src/lib` stays reusable by other apps.
|
||||
- `src/app` modules can be imported from anywhere (routes, components, other modules, the boot
|
||||
sequence) without pulling in UI.
|
||||
- `core.ts` can't import the policy modules that depend on it, so they push themselves onto
|
||||
`appPolicies` when imported, and `core.ts` builds the `App` lazily after they have registered.
|
||||
`flotilla-state` covers this under "App policies".
|
||||
|
||||
### Exceptions
|
||||
|
||||
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.
|
||||
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
|
||||
|
||||
## Top-level layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `src/routes` | SvelteKit pages; the root `+layout.svelte` also runs the boot sequence |
|
||||
| `src/app` | Flotilla's state, policies and feature logic, plus `components/` |
|
||||
| `src/lib` | App-agnostic utilities and the design-system components |
|
||||
| `src/assets/icons` | SVG icons |
|
||||
| `static/` | Logo, PWA icons, fonts, sounds |
|
||||
| `android/`, `ios/` | Capacitor native projects, with flotilla's own plugins and iOS share extension |
|
||||
| `electron/` | Desktop shell on `@capawesome/capacitor-electron`; a separate npm project |
|
||||
| `server.js` | Optional node server: serves `build/` and adds link-preview metadata |
|
||||
| `e2e/` | Playwright suite against a real relay; start with `e2e/ARCHITECTURE.md` |
|
||||
| `scripts/` | Build, desktop, version-bump and welshman-linking scripts |
|
||||
| `docs/feature_matrix.html` | Standalone feature matrix page |
|
||||
|
||||
## `src/app` by concern
|
||||
|
||||
`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions.
|
||||
|
||||
**Core and session**
|
||||
- `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
|
||||
- `session.ts`: restores the saved session at boot; `logout`
|
||||
- `policies.ts`: the ingest, auth and socket policies installed on every app
|
||||
- `storage.ts`: `kv`/`ss` (Capacitor Preferences and SecureStorage) and the per-user IndexedDB
|
||||
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)
|
||||
- `env.ts`: every `VITE_` value, parsed
|
||||
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
|
||||
device id)
|
||||
|
||||
**Navigation and UI plumbing**
|
||||
- `routes.ts`: path builders (`makeSpacePath`, `makeContentPath`, …), `goTo*`, history tracking
|
||||
- `modal.ts`, `modal.svelte.ts`: `pushModal`, `popModal`, `navigate`, and the modal stack
|
||||
- `toast.ts`, `title.ts`, `theme.ts`, `icons.ts` (icon picker options), `drafts.ts`
|
||||
- `editor/`: flotilla's `@welshman/editor` setup (`makeEditor`), with its suggestion popovers and
|
||||
node views
|
||||
|
||||
**Spaces, rooms and administration**
|
||||
- `relays.ts`: relay URL encoding for routes, socket status, LiveKit 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)
|
||||
- `actionItems.ts`: the admin review queue (reports and pending joins)
|
||||
- `featured.ts` (relay-signed featured content), `roomPins.ts`, `commands.ts` (NIP-CD slash
|
||||
commands)
|
||||
- `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
|
||||
- `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)
|
||||
|
||||
**Messaging and calls**
|
||||
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices)
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
## `src/lib`
|
||||
|
||||
Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman, but never `@app`,
|
||||
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`
|
||||
- `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
|
||||
- `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`)
|
||||
|
||||
## 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:
|
||||
|
||||
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.
|
||||
4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`,
|
||||
`syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`.
|
||||
|
||||
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`
|
||||
covers the gate, login and logout.
|
||||
|
||||
## Platform layer
|
||||
|
||||
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
|
||||
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`.
|
||||
- **`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
|
||||
the app works from any static host.
|
||||
|
||||
Platform checks call Capacitor directly. There is no wrapper module:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
The iOS flags exist because of App Store payment policy, so anything that takes money checks
|
||||
`ENABLE_ZAPS` or `HOSTING_ENABLED`. `isMobile` from `@lib/html` detects a touch screen and says
|
||||
nothing about the platform.
|
||||
|
||||
Flotilla's own native code:
|
||||
|
||||
- `android/app/src/main/java/social/flotilla/`: `AndroidPushFallbackPlugin` and its worker (push
|
||||
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`.
|
||||
|
||||
`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.
|
||||
|
||||
## Env and branding
|
||||
|
||||
`src/app/env.ts` reads the `VITE_` values and exports them as typed constants, with relay lists
|
||||
parsed by `fromCsv` and `normalizeRelayUrl`. In DEV each lookup checks `window.__TEST_ENV__` first,
|
||||
which lets the e2e harness point a browser at its own relays. Elsewhere, only `logger.ts` and the
|
||||
about page read a `VITE_` value (`VITE_BUILD_HASH`); other `import.meta.env` reads are `DEV`
|
||||
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
|
||||
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.
|
||||
- A non-empty `VITE_PLATFORM_RELAYS` turns on platform mode, which disables space browsing and
|
||||
makes the first platform relay the home page (`goToHome` in `routes.ts`, `PrimaryNav`,
|
||||
`sync.ts`).
|
||||
- `VITE_THEME`, exported as `FL_THEME`, selects the design preset in
|
||||
`src/lib/components/theme.css`.
|
||||
- The native app name is hard-coded in `capacitor.config.ts` (`appName: "Flotilla"`), outside the
|
||||
env system.
|
||||
|
||||
To add a variable, give it a default in `.env` and export a parsed constant from `env.ts`. If it
|
||||
names a relay or host, the e2e harness has to override or mock it (see "Containment" in
|
||||
`e2e/ARCHITECTURE.md`).
|
||||
|
||||
## Tooling and tests
|
||||
|
||||
- `pnpm run lint` runs prettier and eslint over `src`, `e2e` and the configs, `pnpm run check`
|
||||
runs svelte-check, and `pnpm run format` formats changed files.
|
||||
- `.husky/pre-commit` runs lint and check, and refuses to commit while a `link:` override is in
|
||||
place. CI (`.gitea/workflows/ci.yml`) runs lint, check and the Electron TypeScript build, plus a
|
||||
full build on pushes to `dev`.
|
||||
- Flotilla has no unit tests. `e2e/` is a Playwright suite against a real zooid relay in Docker;
|
||||
`e2e/ARCHITECTURE.md` explains the harness and `e2e/USER_STORIES.md` lists the stories the specs
|
||||
cite. Agents don't run it. Its only footprint in the app is `src/lib/test/`.
|
||||
- `scripts/link-deps.mjs` links `../welshman/packages/*` by writing temporary `link:` overrides
|
||||
into `pnpm-workspace.yaml`, installing, and restoring the file. Without those overrides welshman
|
||||
comes from the registry, so read its source under `node_modules/@welshman/*/dist`, or in
|
||||
`../welshman` when that checkout matches the installed version.
|
||||
|
||||
## Principles behind placement
|
||||
|
||||
**Check welshman before writing flotilla code.** Several commits replace app code with welshman
|
||||
primitives: `render.ts` uses welshman's `summarize` (`9b0d8a55`), `actionItems.ts` uses
|
||||
`rooms.get().pendingJoins` (`3d66fb31`), and `rooms.ts` uses the membership helpers (`847d8984`).
|
||||
|
||||
**When welshman lacks something, add it there.** The maintainer also maintains welshman, so a
|
||||
missing primitive goes upstream rather than into an `@app` workaround. The `Command` and
|
||||
`Pinboard` kinds live in `@welshman/domain`, and flotilla's `commands.ts` and `pinboards.ts` only
|
||||
consume them. Expect rejection for app-level retry loops, liveness heuristics, or registries that
|
||||
duplicate what `@welshman/net` already tracks.
|
||||
|
||||
**Add indirection only when it pays for itself.** `core.ts` exports the `reader`, `writer` and
|
||||
`command` shortcuts because "almost every read or write goes through one of them". Platform checks
|
||||
stay inline rather than going through a platform module, and `drafts.ts` is a module-level `Map`
|
||||
rather than a persisted store.
|
||||
|
||||
## Placement guide
|
||||
|
||||
- **Parsing or building a nostr kind** → upstream in `@welshman/domain`, used through `reader` and
|
||||
`writer` from `@app/core`. See `flotilla-model` ("Adding a kind") and `welshman-domain`.
|
||||
- **A space section for a kind** → a route under `src/routes/spaces/[relay]/`, with the kind in
|
||||
`CONTENT_KINDS`. The walkthrough below names every file involved, and `flotilla-model` ("Adding
|
||||
a kind") and `flotilla-views` ("Adding a space content page") have the checklists. Add the
|
||||
section to the regexes in `server.js`, or its link previews are titled as a room.
|
||||
- **Non-UI logic for one feature** (scoring, filtering, stores keyed by URL) →
|
||||
`src/app/<feature>.ts`, with no component imports.
|
||||
- **A keyed collection of one kind** → a plugin. A generic kind's plugin goes upstream in
|
||||
`@welshman/app`; a flotilla-specific one is a `DerivedPlugin` in `src/app`, exposed with
|
||||
`usePlugin`. See `flotilla-state`.
|
||||
- **A preference** → a `SettingsValues` field in `settings.ts` if it follows the user, or a
|
||||
`kv`/`ss` store if it belongs to the device. See `flotilla-state`.
|
||||
- **A relay or network policy** (what to ingest, when to AUTH, which sockets may open) → an
|
||||
`AppPolicy` in `policies.ts`. See `flotilla-state` and `welshman-net`.
|
||||
- **Data every joined space needs locally** → the filters in `syncSpace` in `sync.ts`. Data that
|
||||
one page needs is loaded by that page. See `flotilla-state` and `flotilla-views`.
|
||||
- **A modal or dialog** → `src/app/components/<Name>.svelte`, opened with `pushModal`. See
|
||||
`flotilla-views`.
|
||||
- **A generic UI primitive** → `src/lib/components/<Name>.svelte` with a CSS family file next to it
|
||||
(`Button.svelte` and `button.css`), and no `@app` imports. See `flotilla-views`.
|
||||
- **A non-nostr HTTP service** → its own module with typed request functions, a typed error class
|
||||
and a base URL from env, as in `hosting.ts` (`hostingFetch`, `HostingError`,
|
||||
`HOSTING_BACKEND_URL`).
|
||||
- **A native capability** → JS in `src/app/<capability>.ts`, with inline `Capacitor` checks and a
|
||||
web fallback. If no Capacitor plugin fits, write one under
|
||||
`android/app/src/main/java/social/flotilla/`, register it in `MainActivity.java`, and bind it
|
||||
with `registerPlugin`. An iOS extension reaches the app through a `flotilla://` deep link.
|
||||
- **A deployment setting** → a `VITE_` variable (see Env and branding).
|
||||
- **Startup wiring** → a `setup*` or `sync*` function in the owning module that returns an
|
||||
`Unsubscriber`, called from the root layout (`setupHistory`, `syncKeyboard`, `Push.sync`).
|
||||
|
||||
## Walkthrough: classifieds
|
||||
|
||||
Classifieds (NIP-99, kind 30402, `CLASSIFIED`) touch every layer and follow current conventions
|
||||
(`e6ce3e5e` is their redesign). Polls, goals, threads and calendar have the same shape without the
|
||||
app module.
|
||||
|
||||
**Domain.** `Classified` in `@welshman/domain` pairs a `ClassifiedReader` (`title()`,
|
||||
`summary()`, `price()`, `status()`, `images()`, `topics()`) with a `ClassifiedWriter` that has the
|
||||
matching setters.
|
||||
|
||||
**Kind registries.** `CONTENT_KINDS` in `src/app/content.ts` drives sync, notifications, push,
|
||||
search and the space nav entry. The kind also appears in `CONTENT_NOUNS`, the kind dispatch in
|
||||
`NoteContent.svelte` and `NoteContentMinimal.svelte`, `makeClassifiedPath` and `makeContentPath` in
|
||||
`routes.ts`, `title.ts`, `NIP46_PERMS` in `nip46.ts`, and the section regexes in `server.js`.
|
||||
`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
|
||||
small function over a domain reader:
|
||||
|
||||
```ts
|
||||
export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active"
|
||||
```
|
||||
|
||||
**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
|
||||
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])`.
|
||||
|
||||
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
|
||||
`makeArticleCreatePath`) instead of in a modal.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `flotilla-state`: the `App` instance and plugins, policies, persistence, sync, publishing
|
||||
- `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-domain`: readers, writers, and adding a kind
|
||||
- `welshman-net`: the pool, sockets and socket policies
|
||||
- `welshman-util`: kind constants, tag specs, `RelaySelection`
|
||||
315
.agents/skills/flotilla-model/SKILL.md
Normal file
315
.agents/skills/flotilla-model/SKILL.md
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
---
|
||||
name: flotilla-model
|
||||
description: "Use this skill when working on how flotilla speaks nostr: publishing or reading content in a space or room, adding or changing an event kind, NIP-29 room state, moderation and room invites, NIP-43 space membership and invite links, NIP-86 relay management and admin gating, NIP-42 auth or NIP-70 protected events, deciding which relays an event goes to, or replacing code that hand-parses tags or builds events with makeEvent."
|
||||
---
|
||||
|
||||
# Flotilla's nostr model
|
||||
|
||||
Flotilla treats a relay as a community (a space) and a NIP-29 group on that relay as a channel (a
|
||||
room). Most of the protocol logic lives below the app: `@welshman/domain` has a Reader/Writer pair
|
||||
per kind, and `@welshman/app` plugins (`Rooms`, `RelayMemberLists`, `RelayManagement`, …) assemble
|
||||
relay state out of those readers. Flotilla's own protocol code is a thin layer on top, mostly in
|
||||
`src/app/access.ts`, `src/app/rooms.ts`, `src/app/management.ts` and the components that publish.
|
||||
|
||||
The per-feature kind inventory is in [kinds.md](kinds.md).
|
||||
|
||||
## Spaces and rooms
|
||||
|
||||
**A space is a relay URL.** No event defines one; the normalized URL is the identity, and routes
|
||||
carry it as `encodeRelay(url)` (`src/app/relays.ts`) under `/spaces/[relay]`. Any relay can be
|
||||
opened as a space. NIP-29 support only decides whether it has rooms.
|
||||
|
||||
**A room is a NIP-29 group `h` on that relay.** The same `h` can exist independently on several
|
||||
relays, so anything room-scoped is keyed by both: `makeRoomKey(url, h)` from `@welshman/app` gives
|
||||
`${url}'${h}`, and `isRoomId` in `src/app/rooms.ts` tests for the `'`. Other relay-scoped keys use
|
||||
`|` (`${url}|${d}` for relay roles and NIP-CD commands) so the two can't collide. Room content
|
||||
carries `["h", h]`; content with no `h` belongs to the whole space. `/spaces/[relay]/chat`
|
||||
(`makeSpaceChatPath`) is the space-wide chat, which is `RoomChat` with no `h`.
|
||||
|
||||
**The user's spaces are their kind 10009 `ROOMS` list** (`RoomList` factory, `RoomLists` plugin):
|
||||
`r` tags for spaces, `group` tags for rooms with the space URL as the hint. `userSpaceUrls` and
|
||||
`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.
|
||||
|
||||
### NIP-11 relay info
|
||||
|
||||
The `Relays` plugin (`relays` in `src/app/core.ts`) fetches each relay's NIP-11 document into a
|
||||
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(70)` | whether space content is marked protected (below) |
|
||||
| `self` | the relay's own pubkey, the trust anchor for relay-signed state |
|
||||
| `redirect_to` | the relay has moved. The space layout offers `SpaceRedirect`, which runs `roomLists.migrateRelay` and `goToMovedSpace` |
|
||||
| `hasNip(50)`, `hasNip("BUD-02")`, `hasNip("9a")` | search, blossom uploads, push |
|
||||
|
||||
## Relay-signed state
|
||||
|
||||
The relay publishes NIP-29 and NIP-43 state under its NIP-11 `self` key: room metadata, admins,
|
||||
members and pins, the space member list, and roles. Anyone can publish events of those kinds, so
|
||||
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, read through `deriveRelaySignedEvents(url, filters)` in `src/app/repository.ts`, as
|
||||
`src/app/featured.ts` does, rather than a bare `deriveEventsForUrl`.
|
||||
|
||||
Content the space owns is published as the relay: `command.publishAsRelay(url)` has the relay
|
||||
sign the event with its own key through the NIP-86 `signevent` method, then sends it back.
|
||||
Featured content (`setFeaturedContent` in `src/app/featured.ts`) and library shelves and pins
|
||||
(`PinboardEdit`, `PinAdd`, `PinEdit`, `PinMenu`, `BoardMenu`) are written this way, which is why the
|
||||
library lists boards with `Pinboards.forAuthor($relay.self)`. Only users the relay allows to call
|
||||
`signevent` can do it, so `SpaceMenuNavItems` shows the library when the space already has boards or
|
||||
lists `signevent` among its supported methods.
|
||||
|
||||
## NIP-29 rooms
|
||||
|
||||
| Constant | Kind | Factory | Author | Flotilla use |
|
||||
|---|---|---|---|---|
|
||||
| `ROOM_META` | 39000 | `RoomMeta` | relay | name, about, picture, flags |
|
||||
| `ROOM_ADMINS` | 39001 | `RoomAdmins` | relay | room admins |
|
||||
| `ROOM_MEMBERS` | 39002 | `RoomMembers` | relay | member snapshot |
|
||||
| `ROOM_PINS` | 39005 | `RoomPins` | relay | pinned messages |
|
||||
| `ROOM_ADD_MEMBER` / `ROOM_REMOVE_MEMBER` | 9000 / 9001 | `RoomAddMember` / `RoomRemoveMember` | admin | `addRoomMembers`, `RoomMemberMenu` |
|
||||
| `ROOM_EDIT_META` | 9002 | `RoomEdit` | admin | `rooms.editRoom` |
|
||||
| `ROOM_CREATE` / `ROOM_DELETE` | 9007 / 9008 | `RoomCreate` / `RoomDelete` | admin | `RoomForm`, `RoomDetailMenu` |
|
||||
| none (`ROOM_CREATE_INVITE` in `src/app/access.ts`) | 9009 | none | admin | `publishRoomInvite` |
|
||||
| `ROOM_UPDATE_PINS` | 9010 | `RoomUpdatePins` | admin | `roomPinLists.setPins` |
|
||||
| `ROOM_JOIN` / `ROOM_LEAVE` | 9021 / 9022 | `RoomJoin` / `RoomLeave` | user | `joinRoom` / `leaveRoom` in `src/app/access.ts` |
|
||||
| `ROOM_CREATE_PERMISSION` | 19004 | `RoomCreatePermission` | not checked | `deriveUserCanCreateRoom` |
|
||||
|
||||
`@welshman/util` also defines `ROOM_ADD_PERM` (9003), `ROOM_REMOVE_PERM` (9004),
|
||||
`ROOM_DELETE_EVENT` (9005) and `ROOM_EDIT_STATUS` (9006); flotilla uses none of them. An admin
|
||||
removes a message with NIP-86 `banEvent` instead (`RoomItemMenu`, `EventMenu`).
|
||||
|
||||
### How `Rooms` builds a room
|
||||
|
||||
`rooms.get().forRoom(url, h)` yields `{id, url, h, meta, members, admins}` from the three
|
||||
relay-signed state kinds. A `ROOM_DELETE` tombstones the room when it is at least as new as all of
|
||||
that state, so a room re-created after deletion comes back. Membership (`members(url, h)`,
|
||||
`membershipStatus(url, h)`) replays the 39002 snapshot, then newer 9000/9001 ops authored by an
|
||||
admin or the relay, then pending 9021/9022 requests, into `MembershipStatus.Initial | Pending |
|
||||
Granted`. `pendingJoins(url, h?)` lists unanswered join requests, and `deriveSpaceActionItems`
|
||||
(`src/app/actionItems.ts`) merges them with reports into the admin queue.
|
||||
|
||||
`src/app/rooms.ts` puts space authority on top:
|
||||
|
||||
- `deriveUserIsRoomAdmin`: a space admin administers every room.
|
||||
- `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.
|
||||
|
||||
### Room flows
|
||||
|
||||
- **Create** (`RoomForm.svelte`): `rooms.createRoom` (9007, `h` from `randomId()`), tolerating an
|
||||
"already" error, then `rooms.editRoom` (9002) with metadata and flags, then `joinRoom`.
|
||||
- **Delete** (`RoomDetailMenu.svelte`): `rooms.deleteRoom` (9008), then `roomLists.removeRoom`.
|
||||
- **Join and leave** (`joinRoom`, `leaveRoom` in `src/app/access.ts`): two publishes, the
|
||||
9021/9022 the relay may refuse and the user's 10009 list, which is what puts the room in the
|
||||
sidebar. `isMembershipRefusal` counts `duplicate:` and "already a member" replies as success.
|
||||
- **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.
|
||||
|
||||
The relay enforces the `RoomMetaReader` flags (`isClosed`, `isHidden`, `isPrivate`,
|
||||
`isRestricted`); the UI only reflects them. Until membership is `Granted`, `RoomChat` hides a
|
||||
private room's messages and blocks posting to a restricted room, and `RoomDetail` describes the
|
||||
flags.
|
||||
|
||||
## NIP-43 space membership
|
||||
|
||||
| Constant | Kind | Factory | Flotilla use |
|
||||
|---|---|---|---|
|
||||
| `RELAY_MEMBERS` | 13534 | `RelayMembers` | relay-signed member list, `relayMemberLists.forUrl(url)` |
|
||||
| `RELAY_ADD_MEMBER` / `RELAY_REMOVE_MEMBER` | 8000 / 8001 | `RelayAddMember` / `RelayRemoveMember` | membership ops; the space chat shows 8000 the way a room shows 9000 |
|
||||
| `RELAY_JOIN` | 28934 | `RelayJoin` | join request carrying a `claim` (`publishJoinRequest`) |
|
||||
| `RELAY_INVITE` | 28935 | `RelayInvite` | unused since claims moved to NIP-86 |
|
||||
| `RELAY_LEAVE` | 28936 | `RelayLeave` | `publishLeaveRequest` |
|
||||
| `RELAY_ROLE` | 33534 | `RelayRole` | relay-signed role definitions (`relayRoles`) |
|
||||
|
||||
Roles are a flotilla extension. A member tag is `["member", pubkey, ...roleIds]`, and
|
||||
`deriveSpaceMemberRoles` in `src/app/roles.ts` parses the role ids because `RelayMembersReader`
|
||||
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`):
|
||||
|
||||
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`,
|
||||
`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`,
|
||||
`SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`.
|
||||
|
||||
## NIP-86 relay management
|
||||
|
||||
`relayManagement.get().forUrl(url)` returns welshman's `ManagementApi`: JSON-RPC over HTTP at the
|
||||
relay's URL, each call signed with a fresh NIP-98 event. Every method resolves to
|
||||
`{result, error}`.
|
||||
|
||||
| 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` |
|
||||
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
|
||||
| `signEvent` | `Command.publishAsRelay` |
|
||||
|
||||
Admin status is inferred. A relay answers `supportedmethods` with everything it implements rather
|
||||
than what the caller may use, and refuses non-admins outright, so `deriveUserIsSpaceAdmin(url)`
|
||||
only means the list came back non-empty (re-checked at most every five minutes per URL). To gate
|
||||
one capability, check the method (`$supportedMethods.includes("signevent")`) and still handle an
|
||||
error from the call, since a listed method can be blocked for a particular user.
|
||||
`deriveUserCanCreateRoom` adds `ROOM_CREATE_PERMISSION` grants to space admins.
|
||||
|
||||
The hosting backend in `src/app/hosting.ts` is a separate HTTP API at `HOSTING_BACKEND_URL` for
|
||||
relays the platform hosts. It authenticates with one NIP-98 header per pubkey, cached for a TTL,
|
||||
instead of signing every call. `spaces/[relay]/admin` is its page, not a NIP-86 console. Hosting is
|
||||
off on iOS (`HOSTING_ENABLED`).
|
||||
|
||||
## Auth, trust and protected events
|
||||
|
||||
- **NIP-42.** `authPolicy` in `src/app/policies.ts` never authenticates to a relay on the user's
|
||||
blocked-relay list and always does under `relay_auth: aggressive`. Under the default
|
||||
`conservative`, it authenticates only to relays in the user's room, relay or messaging-relay
|
||||
lists, or ones they have published to this session. `attemptRelayAccess` authenticates
|
||||
explicitly before a join.
|
||||
- **Refusals.** `mostlyRestrictedPolicy` counts `restricted:` and `blocked:` replies per socket.
|
||||
Once most requests fail, `relaysMostlyRestricted` turns the space status to "Access Denied" and
|
||||
the layout shows `SpaceAuthError`.
|
||||
- **Unsigned events.** Some relays strip signatures (hosted relays have a
|
||||
`policy_strip_signatures` flag). `ingestPolicy` and `trustPolicy` hold back unsigned events
|
||||
unless the relay is in the `trusted_relays` setting, while `SpaceTrustRelay` asks the user.
|
||||
- **NIP-70.** Space content is protected exactly when the relay advertises NIP-70, so every space
|
||||
publish passes `setProtected(await relays.hasNip(url, 70))`. Per NIP-70 (a protocol claim not
|
||||
verified in this repo) a relay then accepts the event only from its author, which keeps space
|
||||
content from being copied elsewhere. The `RelayJoin`, `RelayLeave` and `RelayMembers` writers
|
||||
protect themselves, and `publishReaction` and `retractReaction` in `src/app/reactions.ts` check
|
||||
the relay for their callers.
|
||||
|
||||
## Which relays an event goes to
|
||||
|
||||
Space content goes to the space relay and nowhere else. The writer's routes decide where
|
||||
`command.publish()` sends an event. See flotilla-state for the publishing pipeline itself.
|
||||
|
||||
| Tool | Effect | Use for |
|
||||
|---|---|---|
|
||||
| `writer.setRoom(url, h)` | adds `["h", h]` and forces `relay(url)` | anything in a room |
|
||||
| `writer.forceRoutes(relay(url))` | forces the relay, no `h` | space-wide content, NIP-43 requests |
|
||||
| default routes | the user's outbox plus inboxes of `p`-tagged pubkeys | profile, lists, settings, anything outside a space |
|
||||
| `command.publishToRelays(urls)` | ignores the writer's relays | reactions, deletes, reports, comments, replies |
|
||||
| `command.publishAsRelay(url)` | the relay signs via NIP-86 `signevent` | space-owned content |
|
||||
| `wraps.get().publish({event, recipients})` | a NIP-59 wrap per recipient, to their `MESSAGING_RELAYS` | DMs and DM reactions and deletes |
|
||||
|
||||
`validate()` throws when an `h` tag has no forced route, and every room and relay-membership writer
|
||||
sets `requiresRelays`, so a missing relay fails loudly. A content form forces the space relay and
|
||||
adds `setRoom` only when it is posting into a room, as `ThreadCreate`, `ClassifiedForm`,
|
||||
`CalendarEventForm`, `GoalCreate`, `PollCreate` and the article create route all do.
|
||||
|
||||
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`.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Content kinds
|
||||
|
||||
The space sections are the kinds in `CONTENT_KINDS` (`src/app/content.ts`): `ZAP_GOAL`,
|
||||
`EVENT_TIME`, `THREAD`, `CLASSIFIED`, `POLL`, `PINBOARD` and `LONG_FORM`. Each has list and detail
|
||||
routes under `spaces/[relay]/`, reached through `makeContentPath` in `src/app/routes.ts`. Chat is
|
||||
`MESSAGE` (kind 9). Comments (`COMMENT`, NIP-22) thread under any content kind, and
|
||||
`makeCommentFilter(kinds)` selects them by `#K`. A content form with `shareToChat` set also posts
|
||||
a kind 9 quoting the new event (`publishRoomQuote`), so clients that only render chat still see
|
||||
it. Zaps and goals are off on iOS (`ENABLE_ZAPS` in `src/app/env.ts`).
|
||||
|
||||
[kinds.md](kinds.md) lists every kind by feature, with its factory, module and route.
|
||||
|
||||
### Adding a kind
|
||||
|
||||
1. Add the constant to `@welshman/util` and a factory to `@welshman/domain` upstream (see
|
||||
welshman-domain, "Adding a new kind").
|
||||
2. Publish through `writer(Factory)` and `command(...)`, with `setRoom` or `forceRoutes` and
|
||||
`setProtected(await relays.hasNip(url, 70))` for space content.
|
||||
3. For a new space section, add the kind to `CONTENT_KINDS` (which feeds sync, notifications, push,
|
||||
search and `SpaceMenuNavItems`), `CONTENT_NOUNS` and `makeContentPath`, and add routes under
|
||||
`spaces/[relay]/`.
|
||||
4. If users sign it through a remote signer, add it to `NIP46_PERMS` in `src/app/nip46.ts`.
|
||||
5. If it is relay-scoped state that has to survive a reload, add it to the `kinds` map in
|
||||
`src/app/storage.ts`.
|
||||
6. If the relay signs it, read it through a `RelaySignedDerivedPlugin` or `deriveRelaySignedEvents`.
|
||||
|
||||
## Parsing and building events
|
||||
|
||||
AGENTS.md gives the rule. In protocol code a hand-built event also loses behavior the writer
|
||||
carries:
|
||||
`RelayJoinWriter` adds `-`, `DeleteWriter.addEvent` copies the target's `h` and routes to its
|
||||
relay, `RoomListWriter` publishes to every listed space, and `validate()` refuses a room event with
|
||||
no relay.
|
||||
|
||||
In order of preference:
|
||||
|
||||
1. The kind's reader: `reader(Thread)(event).title()`, `reader(TimeEvent)(event).start()`.
|
||||
2. Base reader getters when the kind is known (`room()`, `protect()`, `expiration()`, `imeta()`),
|
||||
or the standalone helpers when it isn't (`getImeta`, `getExpiration`, `getReplyTags`,
|
||||
`getCommentTagValues`, `getEmojis`).
|
||||
3. `tagValue(tagSpec("h"), event.tags)` in code that handles many kinds at once (notifications,
|
||||
`makeEventPath`, feed grouping), where no single reader applies.
|
||||
|
||||
When welshman lacks a kind or a getter, add it there rather than working around it in the app. A
|
||||
tag spec is the right tool only for a tag nothing else will read, like the `content` tags on
|
||||
featured-content app data.
|
||||
|
||||
### Known exceptions
|
||||
|
||||
These predate the rule or are waiting on welshman. Don't copy them; fix one when you touch it.
|
||||
|
||||
- **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`
|
||||
(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`
|
||||
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
|
||||
`src/app/content.ts` and `Chat.svelte`.
|
||||
- **Getter missing upstream:** role ids on `member` tags (`src/app/roles.ts`); the legacy `name`
|
||||
fallback for calendar titles (`CalendarEventHeader`).
|
||||
|
||||
Mind the name clash: `ROOM` from `@app/rooms` is the tag name `"h"`, while `ROOM` from
|
||||
`@welshman/util` is kind 35834.
|
||||
|
||||
## Related skills
|
||||
|
||||
- flotilla-architecture: where protocol code sits among the layers and modules
|
||||
- flotilla-state: the `App`, plugins, background sync, persistence, and the publishing pipeline
|
||||
- flotilla-views: routes, components, and loading data from components
|
||||
- welshman-domain: readers, writers, and adding a kind
|
||||
- welshman-util: kind constants, tag specs, `RelaySelection` routing, NIP-42/86/98 helpers
|
||||
- welshman-app: `Rooms`, `RelayManagement`, `RelaySignedDerivedPlugin`, `Command`, `Wraps`
|
||||
- welshman-net: sockets, auth state and socket policies
|
||||
55
.agents/skills/flotilla-model/kinds.md
Normal file
55
.agents/skills/flotilla-model/kinds.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Flotilla kinds by feature
|
||||
|
||||
The kinds flotilla reads and writes, grouped by feature. Constants come from `@welshman/util` and
|
||||
factories from `@welshman/domain` unless noted. Room and space-membership kinds are in the NIP-29
|
||||
and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
|
||||
|
||||
## Space content
|
||||
|
||||
| Feature | Constant (kind) | Factory | Module | Where |
|
||||
|---|---|---|---|---|
|
||||
| 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` |
|
||||
| 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 as the relay |
|
||||
| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `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()` |
|
||||
|
||||
## Interactions
|
||||
|
||||
| Feature | Constant (kind) | Factory | Module | Where |
|
||||
|---|---|---|---|---|
|
||||
| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, the `*Actions` components |
|
||||
| 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` |
|
||||
|
||||
## Direct messages
|
||||
|
||||
| Feature | Constant (kind) | Factory | Module | Where |
|
||||
|---|---|---|---|---|
|
||||
| DMs | `DIRECT_MESSAGE` (14), `DIRECT_MESSAGE_FILE` (15), wrapped as `WRAP` (1059) | `DirectMessage` for 14, bypassed in `Chat.svelte`; none for 15 | `src/app/chats.ts` | `chat`, `chat/[chat]` (`Chat`) |
|
||||
|
||||
Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/sync.ts`), and
|
||||
`goToChat` in `src/app/routes.ts` asks for messaging relays (`ChatEnable`) before opening a chat.
|
||||
|
||||
## User data
|
||||
|
||||
| Feature | Constant (kind) | Factory | Module | Where |
|
||||
|---|---|---|---|---|
|
||||
| Profile | `PROFILE` (0) | `Profile` | none | `settings/profile`, `SignUp`, `ProfileDelete` |
|
||||
| Settings | `APP_DATA` (30078), `d` = `flotilla/settings`, encrypted | `AppData` | `src/app/settings.ts` | `settings/*` |
|
||||
| Status (NIP-38) | `STATUS` (30315), `d` = `general` | none | `src/app/statuses.ts` | `ProfileStatus` |
|
||||
| Profile pins | `PINS` (10001) | `PinList` | `src/app/pins.ts` | profile pages |
|
||||
| Spaces and rooms | `ROOMS` (10009) | `RoomList` | `src/app/rooms.ts` | the sidebar, `spaces` |
|
||||
| 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 |
|
||||
442
.agents/skills/flotilla-state/SKILL.md
Normal file
442
.agents/skills/flotilla-state/SKILL.md
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
---
|
||||
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)."
|
||||
---
|
||||
|
||||
# Flotilla state
|
||||
|
||||
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
|
||||
seen it.
|
||||
|
||||
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
|
||||
|
||||
## The app instance
|
||||
|
||||
`src/app/core.ts` builds the app lazily. `app` is a hand-written `ReadableWithGetter<App>`
|
||||
whose first `get()` or `subscribe` builds an anonymous app. `App` runs its policies in its
|
||||
constructor. Flotilla's own policies live in modules that import `core.ts` and push themselves
|
||||
onto `appPolicies` when imported, so the first app has to be built after those imports run (see
|
||||
[App policies](#app-policies)).
|
||||
|
||||
- **Login swaps the app.** `login(session)` builds a `User` from the session, cleans up the old
|
||||
app, builds a new one with that user, then sets `session`. There is no account switching, so
|
||||
`login` only runs while signed out: `restoreSession` at boot, the `LogIn*`/`SignUp*` flows,
|
||||
and `loginWithPomade`.
|
||||
- **Logout reloads the page.** `logout` in `src/app/session.ts` clears `kv`, `ss`, the user's
|
||||
IndexedDB and `localStorage`, cleans up the app, then sets `window.location.href = "/"`.
|
||||
|
||||
The app is therefore stable for as long as anything under the login gate is mounted.
|
||||
|
||||
| Export | What it is | Signed out |
|
||||
|---|---|---|
|
||||
| `app` | the current `App` | an anonymous app |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `reader`, `writer`, `command` | `Domain` entry points on the current app | — |
|
||||
| `login`, `appPolicies` | see above and below | — |
|
||||
|
||||
AGENTS.md lists `pubkey` and `signer` stores, but neither exists. Read `$app.user?.pubkey` where
|
||||
absence is legitimate, and `$user.pubkey` or `user.get().signer` behind the login gate.
|
||||
|
||||
## Signed in vs signed out
|
||||
|
||||
`src/app/components/AppContainer.svelte` renders the route (`children`) only when
|
||||
`$app.user?.pubkey` is set, and shows the `Landing` dialog otherwise. Route pages, and
|
||||
everything under `PrimaryNav`, can assume a user.
|
||||
|
||||
The following run outside that gate:
|
||||
|
||||
- the root `src/routes/+layout.svelte` and what it starts: `restoreSession`,
|
||||
`syncApplicationData`, the `notifications.sync*` functions, `Push.sync`, logging
|
||||
- `ModalContainer` and every modal, including the `LogIn*`/`SignUp*` flows. Modals stay mounted
|
||||
across a login.
|
||||
- `Toast`, `CallBanner`, `SpeechBanner`, `NewNotificationSound`
|
||||
- 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.
|
||||
|
||||
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
|
||||
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
|
||||
(`management.ts`), so only use them from gated components.
|
||||
|
||||
## Reaching plugins
|
||||
|
||||
The code reaches plugins three ways:
|
||||
|
||||
```typescript
|
||||
// Components: a store exported from core.ts, when there is one
|
||||
const display = $profiles.display(pubkey, [url]).$
|
||||
|
||||
// Components: $app.use() for plugins core.ts doesn't export (Zappers, Feeds, Pinboards)
|
||||
const zapper = $app.use(Zappers).forPubkey(pubkey, removeUndefined([url])).$
|
||||
|
||||
// Module scope: always through a store that rebinds when the app changes
|
||||
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
|
||||
```
|
||||
|
||||
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`,
|
||||
`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
|
||||
cannot change while that code is mounted.
|
||||
|
||||
For a new module-level store over a welshman plugin, use the `usePlugin` export in `core.ts` if
|
||||
there is one. Add an export when several modules need the plugin, and otherwise write
|
||||
`fromApp($app => ...)` where the store is defined.
|
||||
|
||||
### Per-identity caches
|
||||
|
||||
Bookkeeping for one identity lives on a plugin instance, so it is discarded with the app.
|
||||
`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.
|
||||
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
|
||||
`@welshman/lib` to share one store per url across every component that asks.
|
||||
|
||||
## Flotilla's own plugins
|
||||
|
||||
| Plugin | Base | What it is |
|
||||
|---|---|---|
|
||||
| `Settings` (`settings.ts`) | `DerivedPlugin` | encrypted app-data settings, plus a `values` projection |
|
||||
| `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 |
|
||||
|
||||
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
|
||||
|
||||
```typescript
|
||||
export class Statuses extends DerivedPlugin<TrustedEvent> {
|
||||
constructor(app: IApp) {
|
||||
super(app, {filters: [filter], eventToItem: event => event, getKey: event => event.pubkey})
|
||||
}
|
||||
|
||||
fetch(pubkey: string, hints: string[] = []) {
|
||||
return this.app.use(Network).loadUsingOutbox(pubkey, filter, hints)
|
||||
}
|
||||
}
|
||||
|
||||
export const statuses = usePlugin(Statuses)
|
||||
```
|
||||
|
||||
A plugin fits a keyed collection of an event kind that needs `index`, `one` and `load`, or
|
||||
per-identity logic with its own caches. Expose derived views as projections with
|
||||
`projectFrom(this.index, ...)`, as `Settings.values` and `Commands.forUrl` do.
|
||||
|
||||
A generic nostr kind belongs upstream in `@welshman/app`, with its reader in `@welshman/domain`
|
||||
(see flotilla-model), because the maintainer prefers fixing welshman to working around it here.
|
||||
`Statuses` could move. `Settings`, keyed on the `flotilla/settings` d-tag, stays.
|
||||
|
||||
## App policies
|
||||
|
||||
An `AppPolicy` is `(app) => Unsubscriber`, and `app.cleanup()` tears policies down in reverse.
|
||||
`core.ts` seeds `appPolicies` with welshman's `appPolicyWraps`, `appPolicyRelayStats`,
|
||||
`appPolicyCacheDecrypt` and `appPolicyLogSignerMethods`. It leaves out welshman's
|
||||
`appPolicyIngest` and `appPolicyAuthUnlessBlocked`, because flotilla replaces them:
|
||||
|
||||
| Policy | Module | What it adds |
|
||||
|---|---|---|
|
||||
| `ingestPolicy` | `policies.ts` | drops DVM and ephemeral kinds; skips signature checks for trusted relays |
|
||||
| `authPolicy` | `policies.ts` | NIP-42 by the `relay_auth` setting, conservative or aggressive |
|
||||
| `socketPolicy` | `policies.ts` | blocked relays, `relaysPendingTrust`, `relaysMostlyRestricted` |
|
||||
| `storagePolicy` | `storage.ts` | the per-user IndexedDB cache, only when the app has a user |
|
||||
|
||||
The root layout imports `@app/policies` and `@app/storage` before anything touches the app. To
|
||||
add a policy:
|
||||
|
||||
1. Define it in the module that owns the concern.
|
||||
2. Push it onto `appPolicies` at the bottom of that module.
|
||||
3. Make sure the root layout imports that module ahead of the first `app.get()`.
|
||||
|
||||
Inside a policy, use the `$app` argument. During construction the new app is not in the store
|
||||
yet, so `app.get()` returns the previous, cleaned-up app. On first boot there is no previous
|
||||
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
|
||||
space content be keyed by relay.
|
||||
|
||||
`src/app/repository.ts` wraps the `@welshman/store` derivations in `fromApp`:
|
||||
|
||||
- `deriveEvent`, `deriveEvents`, `deriveEventsById`, `deriveIsDeleted`
|
||||
- relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`,
|
||||
`getEventsForUrl`
|
||||
- `deriveRelaySignedEvents`, which keeps only events signed by the relay's NIP-11 `self` key
|
||||
- `deriveLatestEvent`
|
||||
|
||||
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.
|
||||
|
||||
### Free functions in an app module
|
||||
|
||||
Most derived state is a plain function in the app module that owns its domain, composing plugin
|
||||
projections and repository derivations:
|
||||
|
||||
```typescript
|
||||
// src/app/actionItems.ts
|
||||
export const deriveSpaceActionItems = (url: string) =>
|
||||
derived(
|
||||
[deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$],
|
||||
([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]),
|
||||
)
|
||||
```
|
||||
|
||||
`src/app/rooms.ts` is the fullest example. Names follow the return type:
|
||||
|
||||
- `derive*` returns a store: `deriveUserRooms`, `deriveUserRoomMembershipStatus`
|
||||
- `get*` and `display*` return a snapshot: `displayRoom`
|
||||
- plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls`
|
||||
|
||||
Rules that involve more than one plugin belong in these functions rather than in components.
|
||||
"A space admin is a room admin" lives in `deriveUserIsRoomAdmin`.
|
||||
|
||||
### Hand-built indexes for hot paths
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
## Local and persisted state
|
||||
|
||||
| State | Where | Per user | On logout |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| drafts, dictations | a module `Map`, lost on reload | no | page reloads |
|
||||
|
||||
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
|
||||
|
||||
`storagePolicy` builds a `Storage` only for an app with a user, so a signed-out app caches
|
||||
nothing. Each identity gets its own database, `flotilla-9gl-<pubkey>`. `IDB` reconciles object
|
||||
stores by bumping the database version, so adding or removing a table needs no migration.
|
||||
|
||||
`shouldPersistEvent` keeps:
|
||||
|
||||
- profiles and metadata lists (follows, mutes, relay lists, app data, room lists) from any author
|
||||
- alert kinds
|
||||
- relay- and room-scoped kinds
|
||||
- DMs
|
||||
- room membership changes, only when they tag the user
|
||||
|
||||
Room messages, threads and other content are not kept, and background sync pulls them again.
|
||||
|
||||
- Rows keep each event's relays inline. A relay-scoped event without them can never be keyed to
|
||||
a space again, so it is dropped on load.
|
||||
- `COMMAND` definitions expire after a week.
|
||||
- Boot waits only for events and relays (`storage.get()?.ready`). The other tables load on the
|
||||
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.
|
||||
|
||||
### kv, ss and the two ways to bind them
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
### Settings (`src/app/settings.ts`)
|
||||
|
||||
Settings are an encrypted app-data event with d-tag `flotilla/settings`, read through the
|
||||
`Settings` plugin:
|
||||
|
||||
- `userSettingsValues` is the current user's values merged over `defaultSettings`. `getSetting`
|
||||
is its snapshot, and there are derived helpers such as `deriveShouldNotify`.
|
||||
- `publishSettings(partial)` calls `forceLoad` first, so a write merges onto the latest event
|
||||
rather than a stale cache.
|
||||
- Settings pages bind `createSettingsForm()`. The form adopts the real values when the event
|
||||
finishes decrypting, but only while untouched, so defaults never overwrite real settings.
|
||||
|
||||
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
|
||||
push permission is not.
|
||||
|
||||
`checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to
|
||||
dufflepud's `kv/checked` with NIP-98 auth. That makes it cross-device without publishing an
|
||||
event on every read.
|
||||
|
||||
### Drafts (`src/app/drafts.ts`)
|
||||
|
||||
`DraftKey<T>` is a typed handle over an in-memory `Map`. A draft survives the composer
|
||||
unmounting and a navigation, but not a reload. Key it by context: `RoomCompose` uses
|
||||
`room:${url ?? ""}:${h ?? ""}` and `EventReply` uses `reply:${event.id}:${parent?.id || ""}`.
|
||||
The dictation registry in `dictation.ts` works the same way, so a transcription can finish after
|
||||
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
|
||||
call tears down the previous run.
|
||||
|
||||
- `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's
|
||||
spaces.
|
||||
- `syncUserData` loads the user's relay list, then on each relay-list change their other lists,
|
||||
profile and `Settings`. It also pulls the user's own space and room membership events, and
|
||||
their follows' follow and mute lists.
|
||||
- `syncSpaces` covers each joined space plus the current route's space. It pulls membership,
|
||||
roles, room metadata, pins and livekit state in full, and recent content: a month of it, or a
|
||||
week for reactions and comments.
|
||||
- `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.
|
||||
|
||||
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
|
||||
components; see flotilla-views.
|
||||
|
||||
## Mutations
|
||||
|
||||
The prevailing path runs from a domain writer to a command to a thunk, adapted from
|
||||
`ThreadCreate.svelte`:
|
||||
|
||||
```typescript
|
||||
const eventWriter = writer(Thread)
|
||||
.setContent(content)
|
||||
.setTitle(title)
|
||||
.setProtected(protect)
|
||||
.forceRoutes(relay(url))
|
||||
|
||||
if (room) {
|
||||
eventWriter.setRoom(url, room)
|
||||
}
|
||||
|
||||
const thunk = await command(eventWriter).then(publish)
|
||||
const error = await thunk.waitForError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
```
|
||||
|
||||
`publish` sends to the writer's own routes, which is why the excerpt forces them with
|
||||
`forceRoutes`. `publishToRelays(urls)` and `publishAsRelay(url)` override those routes instead.
|
||||
flotilla-model's "Which relays an event goes to" says which one each kind needs.
|
||||
|
||||
Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(publish)`,
|
||||
`rooms.get().addMember(url, room, pubkey)`, `reactions.get().react(event, content, ...)`,
|
||||
`deletes.get().deleteEvent(event, w => w.setProtected(protect))`. Their `update`-style methods
|
||||
`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. Room chat
|
||||
(`RoomChat.svelte`) does, because `Command` cannot carry the `send_delay` window, and so do
|
||||
`publishRoomQuote` in `rooms.ts`, 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
|
||||
`{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
|
||||
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.
|
||||
- **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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
## Runes and stores
|
||||
|
||||
Modules in `src/app` use svelte stores. The one `.svelte.ts` module is `src/app/modal.svelte.ts`:
|
||||
its modal registry is `$state`, and the open stack is `$derived` from `page.state` in
|
||||
`$app/state`, SvelteKit's rune-based replacement for the deprecated `$app/stores`. A rune-only
|
||||
source is what justifies the exception. `sync.ts` and `notifications.ts` read `page` from
|
||||
`$app/stores` because they subscribe to it outside a component.
|
||||
|
||||
Component-local `$state` covers UI state that dies with the component. Everything else is a
|
||||
store, consumed in components with `$store`.
|
||||
|
||||
## Where does this state belong?
|
||||
|
||||
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
|
||||
`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
|
||||
upstream in `@welshman/app`. A flotilla-specific one is a `DerivedPlugin` here, exposed with
|
||||
`usePlugin` (`Settings`, `Statuses`, `Commands`).
|
||||
3. **It is bookkeeping for one identity.** Put it on a plugin instance (`Commands.pulled`) or in
|
||||
a policy, never in a module-level map that outlives login. Retry or resume logic around
|
||||
welshman behaviour is a fix for welshman instead.
|
||||
4. **It is a preference.** If it follows the user, it is a `SettingsValues` field. If it is per
|
||||
device, it is a `synced` store in `kv`. A secret goes in `ss`.
|
||||
5. **It is app-wide state that does not come from nostr.** Make it a writable in the owning app
|
||||
module (`callState`, `toast`, `relaysPendingTrust`).
|
||||
6. **It must outlive a component but not a reload.** Use a module map, as `DraftKey` and the
|
||||
dictation registry do.
|
||||
7. **It is one component's UI.** Use `$state` in the component.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `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-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
|
||||
467
.agents/skills/flotilla-views/SKILL.md
Normal file
467
.agents/skills/flotilla-views/SKILL.md
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
---
|
||||
name: flotilla-views
|
||||
description: "Use this skill when adding or changing a route, layout, page, or component in flotilla: deciding between src/lib/components and src/app/components, naming a component, choosing its props, navigating or opening a modal, drawer, popover or toast, building a create/edit form, loading data from a page or component (detail pages, feeds, infinite scroll), wiring a button to a mutation with loading and error states, or styling a component."
|
||||
---
|
||||
|
||||
# Flotilla views: routes, components, and how they reach state
|
||||
|
||||
Pages read their params, load what they show, and hand identifiers to components. Components are
|
||||
flat, noun-first, and derive the rest from those identifiers.
|
||||
|
||||
## Routing
|
||||
|
||||
### No load functions
|
||||
|
||||
Nothing renders on a server (`ssr = false`; see flotilla-architecture for the build), so there are
|
||||
no `+page.ts` files. Every page loads its own data in `onMount` or an `$effect`, and every layout
|
||||
is a `+layout.svelte`.
|
||||
|
||||
### The tree
|
||||
|
||||
```
|
||||
/ redirect: goToHome() /home dashboard (Home* sections)
|
||||
/spaces your spaces + discovery /spaces/create
|
||||
/spaces/[relay] mobile space menu; desktop redirects to goToSpace()
|
||||
/spaces/[relay]/[h] room chat /spaces/[relay]/chat space-level chat
|
||||
/spaces/[relay]/{about,admin,directory,library}
|
||||
/spaces/[relay]/{threads,goals,polls}[/[id]]
|
||||
/spaces/[relay]/{classifieds,articles,calendar}[/[address]] articles/create
|
||||
/chat, /chat/[chat] DMs /people/[npub] profile page
|
||||
/settings/{profile,alerts,wallet,hosting,content,privacy,theme,about}
|
||||
/join invite link landing /share share-intent landing
|
||||
/[bech32] any nip19 entity, resolved and redirected
|
||||
```
|
||||
|
||||
### Params
|
||||
|
||||
- `[relay]`: `encodeRelay(url)` and `decodeRelay(param)` in `src/app/relays.ts`. Encoding strips
|
||||
`wss://` and the trailing slash and URI-encodes the rest; decoding normalizes it back. Build
|
||||
paths with the helpers in `src/app/routes.ts` rather than by hand.
|
||||
- `[h]`: the NIP-29 room id, unencoded (`makeRoomPath(url, h)`). Static segments win over it,
|
||||
which `makeSpaceChatPath(url)` relies on: it is `makeRoomPath(url, "chat")` and lands on the
|
||||
static `chat` page. A new static segment under `[relay]` shadows any room with that id.
|
||||
- `[id]` for regular events, `[address]` for addressable ones. `makeSpacePath(url, ...extra)`
|
||||
URI-encodes each extra segment and drops `undefined`, so `makeClassifiedPath(url, address)` is
|
||||
safe. `[chat]` is `makeChatId(pubkeys)` from `src/app/chats.ts`, and `[npub]` comes from
|
||||
`makeProfilePath(pubkey)`.
|
||||
- State that shouldn't be a route goes in the query string: `?at=` (jump to a message), `?topic=`,
|
||||
`?board=`, `?page=`, and `?h=&shareToChat=1` on article create.
|
||||
|
||||
Pages read params once into constants:
|
||||
|
||||
```typescript
|
||||
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
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
|
||||
`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`.
|
||||
|
||||
### Layouts
|
||||
|
||||
- `src/routes/+layout.svelte` runs the boot sequence, renders `AppContainer` and
|
||||
`ModalContainer`, and sets `document.title` from `getPageTitle`. `AppContainer` gates the route
|
||||
on a signed-in user (flotilla-state), showing `Landing` in a `noEscape` dialog otherwise.
|
||||
- `spaces/[relay]/+layout.svelte` gates the space, pushing one modal at a time, once per url:
|
||||
`SpaceRedirect` (the NIP-11 document carries a `redirect_to`), `SpaceJoin` (the space is not in
|
||||
the user's room list, checked after a `forceLoad`), `SpaceAuthError`, `SpaceTrustRelay`. It
|
||||
renders `SecondaryNav` with `SpaceMenu` and wraps the page in `Page`, except at the space root.
|
||||
- `settings/+layout.svelte` is a `SecondaryNav` of `SecondaryNavItem` links. A new settings page
|
||||
needs an entry there.
|
||||
- `chat/+layout.svelte` is the conversation list, plus a FAB to start a chat.
|
||||
|
||||
Since the space layout supplies `Page`, a space page renders a `SpaceBar` and a `PageContent`:
|
||||
|
||||
```svelte
|
||||
<SpaceBar>
|
||||
{#snippet leading()}<Icon icon={CaseMinimalistic} />{/snippet}
|
||||
{#snippet title()}<strong>Classifieds</strong>{/snippet}
|
||||
{#snippet action()}
|
||||
<Button class="button button-primary button-sm" onclick={createClassified}>Create</Button>
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
<PageContent bind:element class="@container flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
|
||||
```
|
||||
|
||||
Detail pages also pass `back`, a handler that calls `history.back()`, which `SpaceBar` shows on
|
||||
mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves.
|
||||
|
||||
### URL builders, titles, deep links
|
||||
|
||||
`src/app/routes.ts` is the only place paths are built:
|
||||
|
||||
- `makeSpacePath`, `makeRoomPath`, `makeSpaceChatPath`, `makeProfilePath`, `makeChatPath`, and
|
||||
one builder per content page (`makeThreadPath`, `makeClassifiedPath`, `makeArticlePath`,
|
||||
`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.
|
||||
- `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
|
||||
`setupHistory` records, else chat or about. `goToChat(pubkeys)` checks for messaging relays
|
||||
first, pushing `ChatEnable` if there are none.
|
||||
|
||||
`src/app/title.ts` maps route ids to tab titles. A new static route needs a `staticTitles` entry,
|
||||
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
|
||||
loads events before calling `goToEvent`.
|
||||
|
||||
### Adding a space content page
|
||||
|
||||
1. `src/routes/spaces/[relay]/<name>/+page.svelte`, plus `[id]` or `[address]` for detail.
|
||||
2. `make<Name>Path` in `src/app/routes.ts`, and a case in `makeContentPath`, so notifications and
|
||||
permalinks land there.
|
||||
3. Titles in `src/app/title.ts`.
|
||||
4. A `SecondaryNavItem` in `SpaceMenuNavItems.svelte`. Content entries appear only once the space
|
||||
holds that kind, which comes from `CONTENT_KINDS` in `src/app/content.ts`.
|
||||
|
||||
The registries outside the view layer, including the link-preview server, are in
|
||||
flotilla-architecture and flotilla-model.
|
||||
|
||||
## Navigation, modals, popovers, toasts
|
||||
|
||||
### navigate, not goto
|
||||
|
||||
`navigate(path, {replaceState, keepModal})` in `src/app/modal.ts` wraps `goto`. With a modal open
|
||||
it replaces the modal's history entry, so Back doesn't reopen it, and `keepModal` changes the
|
||||
page underneath while leaving the stack open (`goToHome` uses it). `Link` calls `navigate` and
|
||||
stops propagation, so a link inside a clickable card only follows the link. Plain `goto` survives
|
||||
in pages for query-string updates (`replaceState`, `noScroll`, `keepFocus`) and redirects, where
|
||||
no modal can be open.
|
||||
|
||||
### pushModal
|
||||
|
||||
```typescript
|
||||
pushModal(ClassifiedEdit, {url, event}) // replaces any open modals
|
||||
pushModal(WalletConnect, {}, {nested: true}) // stacks on top of the current one
|
||||
pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) // swaps out the current one
|
||||
pushModal(SpaceMenuDrawer, {url: spaceUrl}, {drawer: true}) // side drawer instead of a dialog
|
||||
```
|
||||
|
||||
Open modal ids live in SvelteKit page state (`App.PageState.modals` in `src/app.d.ts`), the
|
||||
components live in a `$state` record in `src/app/modal.svelte.ts`, and `ModalContainer` mounts
|
||||
each one inside `Dialog` or `Drawer`. Each modal owns a history entry, so Back closes it, and a
|
||||
push without `nested` replaces the whole stack. `replaceState` suits mobile menus that open a
|
||||
follow-up (`RoomItemMenuMobile`) and multi-step flows. `noEscape` removes the close button and
|
||||
ignores Escape and the backdrop; the space gates use it. `ModalOptions.path` is never read.
|
||||
|
||||
Modal components take identifiers like any other component. `Dialog` supplies the chrome, and
|
||||
`/join` renders `SpaceInviteAccept` in a `Dialog` directly. The usual shape:
|
||||
|
||||
```svelte
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Room</ModalTitle>
|
||||
<ModalSubtitle>On <span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
...fields
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>Go back</Button>
|
||||
<Button type="submit" class="button button-primary" disabled={loading}>
|
||||
<Spinner {loading}>Create Room</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
A modal closes itself with `history.back()`, in 116 call sites. `clearModals()` ends a flow that
|
||||
may sit on top of other modals, such as a delete confirmed from a menu. `popModal()` closes the
|
||||
modal before doing something else in the same handler, where `history.back()` would race it:
|
||||
`ProfileDetail` pops before `goToChat`, and `SearchBody` pops before `goToEvent`.
|
||||
|
||||
For yes/no questions, push `Confirm` from lib with `{title, message, confirm}`; it runs `confirm`
|
||||
behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
|
||||
(`EventDeleteConfirm`).
|
||||
|
||||
### Popover menus
|
||||
|
||||
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
|
||||
that hides it:
|
||||
|
||||
```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.
|
||||
|
||||
### Toasts
|
||||
|
||||
`pushToast` in `src/app/toast.ts` shows one toast at a time:
|
||||
|
||||
```typescript
|
||||
pushToast({message: "Role created!"})
|
||||
pushToast({theme: "error", message, action: {message: "Details", onclick}})
|
||||
pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}})
|
||||
```
|
||||
|
||||
A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies
|
||||
to the clipboard and toasts.
|
||||
|
||||
## lib vs app components
|
||||
|
||||
`src/lib/components` knows nothing about the app instance, stores, or nostr kinds. Its components
|
||||
import third-party libraries, `@welshman/lib` helpers, `@lib/*` and `@assets/*`, plus
|
||||
`$app/stores` in `PrimaryNavItem` and `SecondaryNavItem`, which highlight the active path. What
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
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
|
||||
names that break the pattern are in [naming.md](naming.md).
|
||||
|
||||
## Props
|
||||
|
||||
### Identifiers first, the event when you have it
|
||||
|
||||
`url` is a prop in 159 components, `h` in 32 and `pubkey` in 28. Relays, rooms and profiles have
|
||||
stores, so a component takes the key and looks up the rest: `RoomName` takes `{url, h}` and reads
|
||||
`$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
|
||||
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
|
||||
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.
|
||||
|
||||
### The rest of the vocabulary
|
||||
|
||||
- Callbacks are camelCase `on*`: `onSubmit`, `onClose`, `onCancel`, `onReply`, `onSelect`,
|
||||
`onResolved`, plus the `onClick` that closes a popover. `RoomForm` and `ProfileEditForm` take a
|
||||
lowercase `onsubmit`, a leftover.
|
||||
- Steps in a flow take `next`, and the signup steps add `step` and `totalSteps`.
|
||||
- Snippets: `children`; `header` and `footer` on forms; `customActions`, which adds items to
|
||||
`EventActions`, `EventMenu` and `ProfileMenu`; `leading`, `title` and `action` on `SpaceBar`. A
|
||||
snippet can take arguments, as `RoomForm`'s `footer: Snippet<[{loading: boolean}]>` does.
|
||||
- `$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`.
|
||||
|
||||
## 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`,
|
||||
`$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.
|
||||
|
||||
How you bind depends on what the method returns:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
const room = $rooms.forRoom(url, h) // Readable: one, forRoom
|
||||
const display = $profiles.display(pubkey, removeUndefined([url])).$ // Projection: take .$
|
||||
const shouldProtect = $relays.hasNip(url, 70) // Promise: load, hasNip
|
||||
</script>
|
||||
|
||||
{$room?.meta?.name() || h} · {$display}
|
||||
```
|
||||
|
||||
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`,
|
||||
`deriveUserIsSpaceAdmin` 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
|
||||
root event.
|
||||
|
||||
Read an event's tags through a domain reader, `$derived(reader(Classified)(event))`, with
|
||||
`reader` from `@app/core`. `$user` throws signed out, so use it only under the login gate.
|
||||
|
||||
## Loading from the network
|
||||
|
||||
A page or component loads what it displays; a layout loads what it gates on. Background sync for
|
||||
data every page needs lives in `src/app/sync.ts`.
|
||||
|
||||
**One entity, or a handful of lists:** call plugin `load` in `onMount`, and toast on failure, as
|
||||
`people/[npub]/+page.svelte` does for the profile, relay list, follow, pin, room and messaging
|
||||
lists before loading the author's outbox with `$network.load`.
|
||||
|
||||
**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:
|
||||
|
||||
```svelte
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading listing...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load classified listing.</p>
|
||||
{/await}
|
||||
```
|
||||
|
||||
**Related events:** request them, abort on teardown, and read them back from the repository with
|
||||
`deriveEventsAsc(deriveEventsById(filters))`.
|
||||
|
||||
```typescript
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
$network.request({relays, filters, signal: controller.signal})
|
||||
|
||||
return () => controller.abort()
|
||||
})
|
||||
```
|
||||
|
||||
That is `EventComments`. The thread detail page does the same in an `$effect`.
|
||||
|
||||
**A list that pages as you scroll:** `makeFeed`, `makeScrollLoader` and `makeFeedContext` from
|
||||
`src/app/feeds.ts`. Every space list page uses them, as do `HomeNetwork`, `ProfilePageNotes` and
|
||||
`RoomChat`. The threads and classifieds pages are the reference:
|
||||
|
||||
```typescript
|
||||
const context = makeFeedContext({relays: [url]})
|
||||
|
||||
onDestroy(context.cleanup)
|
||||
|
||||
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
|
||||
let element: HTMLElement | undefined = $state()
|
||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||
|
||||
const loading = $derived(isFeedLoading($older))
|
||||
const exhausted = $derived($older?.status === "exhausted")
|
||||
|
||||
onMount(() => {
|
||||
const feed = makeFeed({relays: [url], onEvent: context.add, filters})
|
||||
|
||||
events = feed.events
|
||||
older = makeScrollLoader(element!, feed.loadOlder)
|
||||
|
||||
return () => {
|
||||
older?.stop()
|
||||
feed.cleanup()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
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
|
||||
`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`.
|
||||
|
||||
`ProfileFeed` still drives welshman's `FeedController` through
|
||||
`$app.use(Feeds).makeFeedController` and `createScroller`, but 27fa25c3 moved the home feed onto
|
||||
the app helpers, which is the direction for new lists. Only `RoomChat` virtualizes; other long
|
||||
lists wrap each row's root in `Cv`, which applies `content-visibility` and paints two viewports
|
||||
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:
|
||||
|
||||
```typescript
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const thunk = await command(eventWriter).then(publish)
|
||||
const error = await thunk.waitForError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
history.back()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
`$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`.
|
||||
|
||||
## 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`).
|
||||
|
||||
Create/edit pairs come in three shapes:
|
||||
|
||||
- **Fields only.** `RoleForm` exports `Values` from `<script module>`, takes
|
||||
`initialValues?: Partial<Values>`, `loading` and `onSubmit(values)`, and renders the footer.
|
||||
`RoleCreate` and `RoleEdit` each own their mutation, toast and close. `hosting/RelayForm` does
|
||||
the same with `Pick<HostedRelay, …>`. Use this shape when create and edit differ.
|
||||
- **The form owns the mutation.** `RoomForm` creates, edits and joins, while `RoomCreate` and
|
||||
`RoomEdit` supply `header`, `footer({loading})` and where to go next.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
- Container queries go on the `PageContent`, as in `@2xl:grid-cols-2` for the classifieds grid.
|
||||
- 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-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-domain`: the readers and writers behind `reader` and `writer`
|
||||
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed`
|
||||
- `welshman-editor`: the composer behind `makeEditor`
|
||||
61
.agents/skills/flotilla-views/naming.md
Normal file
61
.agents/skills/flotilla-views/naming.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Component naming vocabulary
|
||||
|
||||
Companion to `flotilla-views`. Names in `src/app/components` are `<Entity><Qualifier>`, flat and
|
||||
PascalCase. This is what the qualifiers mean, drawn from the ~320 components there.
|
||||
|
||||
## Suffixes
|
||||
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `Page` | the body of a route, when the page file delegates | `ProfilePage`, `ProfilePageNotes` |
|
||||
| `Enable` | a gate that sets a feature up before letting you in | `ChatEnable`, `OpenRouterEnable` |
|
||||
|
||||
Verbs also work as qualifiers where no noun fits: `SpaceJoin`, `SpaceExit`, `SpaceInvite`,
|
||||
`ProfileDelete`, `RoomSearch`.
|
||||
|
||||
## Prefix families
|
||||
|
||||
Most prefixes are the entity (`Space` 34 components, `Room` 28, `Profile` 26, `Chat` 11, plus one
|
||||
per content kind). Four are families instead:
|
||||
|
||||
- `Event*` is kind-agnostic event UI: `EventMenu`, `EventActions`, `EventInfo`, `EventComments`.
|
||||
- `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
|
||||
publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
|
||||
|
||||
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,
|
||||
`Landing`, `Profile`, `Reaction`, `Report`, `Search`, `Share`, `Toast`, `Zap`, `Banner`.
|
||||
|
||||
## Outliers
|
||||
|
||||
Don't follow these:
|
||||
|
||||
- Verb-first or verb-in-the-middle: `EditFeaturedContent`, `ShareEvent`, `RoleAddMembers` (next
|
||||
to `RoomMembersAdd`), `WalletUpdateReceivingAddress`, `NewNotificationSound`, `MenuSettings`.
|
||||
- `Modal` and `Dialog` suffixes: `IconPickerModal`, `ImageInputModal`, `VoiceRoomJoinDialog`,
|
||||
`VoiceCallAudioSettingsDialog`, and hosting's `PaymentDialog`, `PlanModal`,
|
||||
`CustomDomainModal`. Everything else is named for what it does, not for being a modal.
|
||||
- `ReportDetails`, plural, next to `ProfileDetail` and `RoomDetail`.
|
||||
- `SpaceMenu` is the space's sidebar navigation, not a popover, and it has its own family:
|
||||
`SpaceMenuHeader`, `SpaceMenuNavItems`, `SpaceMenuRooms`, `SpaceMenuDrawer`.
|
||||
Loading…
Reference in a new issue