Add some skill files

This commit is contained in:
Jon Staab 2026-09-14 11:50:46 -07:00
parent 40889a91cf
commit 75451b71e3
6 changed files with 1681 additions and 0 deletions

View 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`

View 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

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

View 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

View 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`

View 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`.