451 lines
23 KiB
Markdown
451 lines
23 KiB
Markdown
---
|
|
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`
|
|
- `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).$,
|
|
deriveSpaceSupportedMethods(url),
|
|
],
|
|
([$reports, $pendingJoins, $methods]) =>
|
|
sortEventsDesc([
|
|
...($methods.includes("banevent") ? $reports : []),
|
|
...($methods.includes("allowpubkey") ? $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's staff are room admins" 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)` overrides 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. Anything that
|
|
honours the `send_delay` window does, because `Command` cannot carry it: room chat
|
|
(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and
|
|
`publishRoomQuote` in `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
|
|
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
|
|
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
|
|
`{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,
|
|
and a comment carries the same Cancel in the `ThunkPending` row under it.
|
|
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
|
|
`RoomChat.svelte`).
|
|
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
|
|
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
|
|
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
|
|
whole `history` per row instead.
|
|
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
|
|
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
|