diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md new file mode 100644 index 00000000..e4297252 --- /dev/null +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -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/.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/.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/.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/.svelte`, opened with `pushModal`. See + `flotilla-views`. +- **A generic UI primitive** → `src/lib/components/.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/.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` diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md new file mode 100644 index 00000000..96dfc941 --- /dev/null +++ b/.agents/skills/flotilla-model/SKILL.md @@ -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=&c=`, 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 diff --git a/.agents/skills/flotilla-model/kinds.md b/.agents/skills/flotilla-model/kinds.md new file mode 100644 index 00000000..cc850a04 --- /dev/null +++ b/.agents/skills/flotilla-model/kinds.md @@ -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 | diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md new file mode 100644 index 00000000..80f61fd6 --- /dev/null +++ b/.agents/skills/flotilla-state/SKILL.md @@ -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` +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 { + 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-`. `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` 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 diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md new file mode 100644 index 00000000..53ddfb19 --- /dev/null +++ b/.agents/skills/flotilla-views/SKILL.md @@ -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 +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 + + {#snippet leading()}{/snippet} + {#snippet title()}Classifieds{/snippet} + {#snippet action()} + + {/snippet} + + +``` + +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]//+page.svelte`, plus `[id]` or `[address]` for detail. +2. `makePath` 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 + + + + Create a Room + On {displayRelayUrl(url)} + + ...fields + + + + + + +``` + +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 + +``` + +`EventMenu` attaches `onClick` to its `
    `, 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 ``: 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 + + +{$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)} + Loading listing... +{:then} +

    Failed to load classified listing.

    +{/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> = $state() +let element: HTMLElement | undefined = $state() +let events: Readable = $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 +``. `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 `` +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 ``. 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 (`