diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index 5b7ec557..8967adb5 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -20,9 +20,8 @@ and UI. | App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other | | Lib | `src/lib` | `@lib/x` | external packages only | -`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`. -SvelteKit's built-in `$lib` also resolves, but only four stray imports use it (in `relays.ts`, -`callEngine.ts` and `VoiceRoomJoinDialog.svelte`). There is no barrel file, so import each +`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`, not +SvelteKit's built-in `$lib`, which also resolves. There is no barrel file, so import each component by its path (`@lib/components/Button.svelte`), not from `$lib/components` as the AGENTS.md example has it. Icons come from `@assets/icons/.svg?dataurl`. @@ -44,13 +43,16 @@ and `sync.ts`. No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the only gate. -- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`, and `ImageInputButton.svelte` - and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app - behavior takes it as a prop, or moves to `src/app/components`. -- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`, - `ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal - mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor` - mounts. +- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib + component that needs app behavior takes it as a prop, or moves to `src/app/components`, as + `IconInput` and its picker buttons did. +- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens + `NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`), `speech.ts` + (`OpenRouterEnable`) and `healthChecks.ts` (`KeyDownload`, the fix for the key backup check) + import a component so they can open a modal mid-flow. Push adapters and + deep links reach those flows from outside any component, and the rest have several component + callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion + popovers), which `makeEditor` mounts. - Nothing under `src/app` or `src/lib` imports from `src/routes`. ## Top-level layout @@ -71,7 +73,9 @@ only gate. ## `src/app` by concern -`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions. +`src/app/components` is flat except for `hosting/`. It also holds the CSS families for app +concepts (`chat.css`, `room.css`, `role-badge.css`, `space-menu.css`), which `src/app/app.css` +imports. `flotilla-views` covers component conventions. **Core and session** - `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts @@ -81,8 +85,8 @@ only gate. cache - `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs - `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings -- `repository.ts`: `derive*` helpers over the current app's repository -- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking) +- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event +- `publications.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`) - `env.ts`: every `VITE_` value, parsed - `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a device id) @@ -95,7 +99,7 @@ only gate. node views **Spaces, rooms and administration** -- `relays.ts`: relay URL encoding for routes, socket status, LiveKit detection +- `relays.ts`: relay URL encoding for routes, socket status, the LiveKit endpoint and detection - `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces - `access.ts`: joining, invites, relay auth errors - `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles) @@ -105,24 +109,28 @@ only gate. - `hosting.ts`: client for the hosting backend's HTTP API **Content** -- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`) and comment/delete - filters +- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`), comment/delete + filters, and `partitionByActivity`, which the list pages use to sort a kind by latest comment - `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed` - `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts` - `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as - text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom) -- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox) + text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression) +- `notifications.ts` (unread state, badges, the home inbox) **Messaging and calls** -- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices) +- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, the video + tiles a call shows, and the `AbortError`/`TimeoutError` a join rejects with). `call.ts` stays + free of livekit values, since the root layout loads it and `callEngine.ts` is imported lazily. **Identity and payments** - `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts` (prompts for missing inbox/outbox relays) **Platform and voice** -- `push/` (notification adapters), `share.ts`, `keyboard.ts` -- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter +- `push/` (notification adapters, and `server.ts` for the push server's HTTP API), `share.ts`, + `deepLinks.ts`, `keyboard.ts` +- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through `postOpenRouter` in + `openrouter.ts` A feature gets a `src/app/.ts` only when it has non-UI logic to hold. Polls, goals, threads and calendar events have no module; their components use domain readers directly. @@ -133,29 +141,34 @@ Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman, env, or the `App` instance. A good test is whether it would work unchanged in another nostr client. -- `util.ts`: small helpers (`errorMessage`, `AbortError`/`TimeoutError`, `buildUrl`, - `normalizeTopic`) -- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`, `compressFile` +- `util.ts`: small helpers (`errorMessage`, `buildUrl`, `normalizeTopic`) +- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard` - `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on - `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to `@app/feeds`, which loads events. -- `livekit.ts`: finds a relay's LiveKit endpoint +- `audio.ts` (device lists, a live input level, WAV encoding), `tileGrid.ts` (the call grid's + geometry) - `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next) - `test/`: the DEV-only hooks the e2e harness injects through -- `components/`: the design system, entered through `theme.css` (see `flotilla-views`) +- `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which + `src/app/app.css` imports ahead of the app's own CSS families ## Boot sequence `src/routes/+layout.svelte` runs the boot sequence. It imports `@app/policies` for its side effect, and `@app/storage`, which registers `storagePolicy` the same way, so every `AppPolicy` is on -`appPolicies` before anything calls `app.get()`. Then, in order: +`appPolicies` before anything calls `app.get()`. `applySavedTheme()` stamps the last theme and font +size from `localStorage` before first paint. Then, in order: 1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped `App` through `login`. -2. The device, wallet and notification stores sync to `kv`/`ss`. -3. It waits for storage, then handles a cold-start deep link. +2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification + settings, `shouldUnwrap`, `forceHealthChecks`). +3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a + cold-start one. 4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`, - `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`. + `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`, + `syncSignerAlerts`, and `syncTheme`, which mirrors the theme stores back to `localStorage`. When login swaps in a new `App`, the layout runs `syncApplicationData` again. Routes render inside `AppContainer`, behind the login gate, and `ModalContainer` renders outside it. `flotilla-state` @@ -167,10 +180,10 @@ One web build runs in several shells: - **Web/PWA.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest, except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients. -- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build.sh` runs the +- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build/app.sh` runs the web build, `cap sync`, and native asset generation. - **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by - `scripts/build-desktop.sh` and `scripts/dev-desktop.mjs`. + `scripts/desktop/build.sh` and `scripts/desktop/dev.mjs`. - **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's `Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and @@ -179,7 +192,7 @@ One web build runs in several shells: Platform checks call Capacitor directly. There is no wrapper module: ```ts -export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" // src/app/env.ts +export const ENABLE_ZAPS = Capacitor.getPlatform() !== "ios" // src/app/env.ts export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts @@ -195,7 +208,8 @@ Flotilla's own native code: without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with `registerPlugin` (`push/adapters/android.ts`, `share.ts`). - `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a - `flotilla://share` URL. `handleDeepLink` in the root layout passes that to `shareFromNative`. + `flotilla://share` URL. `handleDeepLink` in `src/app/deepLinks.ts` passes that to + `shareFromNative`. `Push` in `src/app/push/index.ts` chooses an adapter at runtime: the Android fallback, Capacitor `PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications. @@ -210,7 +224,7 @@ guards. - `.env` is committed and holds working defaults. `.env.local` (gitignored) overrides it. There is no `.env.template`, though AGENTS.md and the README refer to one. -- Env is read at build time. `scripts/build-web.sh` sources `.env` without overwriting variables +- Env is read at build time. `scripts/build/web.sh` sources `.env` without overwriting variables already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from `src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and `VITE_PLATFORM_DESCRIPTION` at runtime. @@ -309,7 +323,7 @@ search and the space nav entry. The kind also appears in `CONTENT_NOUNS`, the ki `NIP46_PERMS` leaves out polls, articles and goals, so listing a new kind there is optional. **App module.** `src/app/classifieds.ts` holds the listing logic the page would otherwise inline -(`partitionListings`, `deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a +(`deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a small function over a domain reader: ```ts @@ -319,15 +333,16 @@ export const getStatus = (event: TrustedEvent) => reader(Classified)(event).stat **Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and `ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open `ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the -new listing into the room. `ClassifiedActions` opens `ClassifiedEdit`. `ClassifiedItem` is the +new listing into the room. Its `ContentActions` row opens `ClassifiedEdit` through `editForm`. `ClassifiedItem` is the card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used. **Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+page.svelte` reads -one listing with `deriveEvent(address, [url])`. +one listing with `$events.one(address, [url]).$`. Articles are composed on a full page (`spaces/[relay]/articles/create`, built by -`makeArticleCreatePath`) instead of in a modal. +`makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and +renders `ArticleForm`. ## Related skills @@ -335,7 +350,7 @@ Articles are composed on a full page (`spaces/[relay]/articles/create`, built by - `flotilla-views`: routes and layouts, components, modals, loading data from components - `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing - `welshman`: overview of the packages -- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks +- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and the `Publisher` - `welshman-domain`: readers, writers, and adding a kind - `welshman-net`: the pool, sockets and socket policies - `welshman-util`: kind constants, tag specs, `RelaySelection` diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index a379b596..f0221802 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -31,7 +31,8 @@ carries `["h", h]`; content with no `h` belongs to the whole space. `/spaces/[re `deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list", which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte` prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS` -uses `PLATFORM_RELAYS` in place of the list for sync and navigation. +uses `PLATFORM_RELAYS` in place of the list for sync and navigation: `activeSpaceUrls` in +`src/app/rooms.ts` is that choice, read by background sync, notifications and editor suggestions. ### NIP-11 relay info @@ -40,7 +41,7 @@ domain `Relay`. These fields drive protocol decisions: | Read | Decides | |---|---| -| `hasNip(29)` | whether the space has rooms. Without it everything lives in the space chat: `makeSpaceEntryPath` (`src/app/routes.ts`), `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuRooms` | +| `hasNip(29)` | whether the space has rooms, read through `spaceSupportsRooms(url)` / `deriveSpaceSupportsRooms(url)` in `src/app/relays.ts`. Without it everything lives in the space chat (`makeSpaceChatPath`): `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuNavItems`, `SpaceMenuRooms` | | `hasNip(70)` | whether space content is marked protected (below) | | `self` | the relay's own pubkey, the trust anchor for relay-signed state | | `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) | @@ -54,8 +55,8 @@ members and pins, the space member list, and roles. Anyone can publish events of readers must check the author. The welshman collections do: `Rooms` and every `RelaySignedDerivedPlugin` (`RelayMemberLists`, `RelayRoles`, `RoomPinLists`) drop events whose author isn't the relay's `self`, and re-check when NIP-11 loads. For a relay-authored kind with no -plugin, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a -bare `deriveEventsForUrl`. +plugin, read `$events.relaySignedForUrl(url, filters)`, which keeps only events signed by that +`self` key, rather than a bare `$events.forUrl`. The app signs everything it publishes with the user's own key. Space-wide content with no author of its own belongs to the space's owner, the pubkey NIP-11 names: featured content @@ -104,9 +105,9 @@ user holds a method for — reports under `banevent`, join requests under `allow - `deriveUserRoomMembershipStatus`: an admin is always `Granted`. - `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing 9000, because a room member the relay won't serve can't read the room. -- `deriveUserRooms`, `deriveOtherRooms`, `deriveOtherVoiceRooms`: rooms from the user's 10009 - list and the rest of the space, limited to rooms the relay still advertises. `meta.hasLivekit()` - marks a voice room. +- `deriveUserRooms`, `deriveOtherRooms(url, type)`: rooms from the user's 10009 list and the rest + of the space by `RoomType`, limited to rooms the relay still advertises. `meta.hasLivekit()` + marks a voice room (`getRoomType`). ### Room flows @@ -119,7 +120,8 @@ user holds a method for — reports under `banevent`, join requests under `allow - **Invite** (`publishRoomInvite`): a 9009 with a random `code` tag. The link carries `h` and `code`, and `joinRoom(url, h, code)` sends the code as the join's `claim`. - **Pins**: `roomPinLists.setPins(url, h, pins)` sends 9010 and the relay republishes 39005. - `deriveRoomPinnedEvents` (`src/app/roomPins.ts`) loads the pinned events from the room's relay. + `toggleRoomPin(url, h, id)` (`src/app/roomPins.ts`) wraps it for the message menus and toasts + the result. `deriveRoomPinnedEvents` loads the pinned events from the room's relay. The relay enforces the `RoomMetaReader` flags (`isClosed`, `isHidden`, `isPrivate`, `isRestricted`); the UI only reflects them. Until membership is `Granted`, `RoomChat` hides a @@ -143,19 +145,23 @@ has no getter for them yet. Roles are only ever changed over NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing 33534. -Joining a space (`attemptRelayAccess` and `Access` in `src/app/access.ts`): +Joining a space (`attemptRelayAccess` and `completeSpaceJoin` in `src/app/access.ts`): 1. Open the socket and drive NIP-42 auth, retrying up to three times. 2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay. -3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite. -4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`, +3. Translate refusals: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code" + means rejected and "claim" means the space needs an invite. +4. `completeSpaceJoin`: opt into the space's notifications (`optInSpaceNotifications` in + `src/app/push`, which asks for push permission first when push is on), `roomLists.addRelay(url)`, + clear the relay from `relaysMostlyRestricted`, restart sync, and `Sync.push` the user's `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them. Invite links are `${PLATFORM_URL}/join?r=&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`, +`SpaceInviteAccept`, which calls `acceptInvite` to join the space (skipped if already joined) and +then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over +NIP-86 (`supportedmethods`, then `listclaims`, then `createclaim`), publishes a room invite code +for a room, and holds that state itself; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, `SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`. ## NIP-86 relay management @@ -166,17 +172,22 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | Methods | Called from | |---|---| -| `supportedMethods` | `deriveSpaceSupportedMethods` (`src/app/management.ts`), `Access.prepareInvite` | -| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` | -| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) | -| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` | -| `listClaims`, `createClaim` | `Access.prepareInvite` | +| `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) | +| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) | +| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) | +| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` | +| `listClaims`, `createClaim` | `InviteLink` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control -is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every -five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an -error from the call, since a listed method can be refused for a particular event or target. +is gated on the method behind it through `deriveSpacePermissions(url)`, a store of named booleans +(`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether +to render a menu (`memberMenu`, `roleMenu`, `bannedMenu`, `directoryMenu`) are defined there too, +so parent and menu can't disagree. The `SpaceManagement` plugin (`spaceManagement`) caches the +methods per app, re-checked at most every five minutes per URL, and `InviteLink` reads +them through `loadSupportedMethods`. It also holds the admin and ban lists, which +`loadMethodAssignees`/`loadBannedPubkeys` refresh after a change. Still handle an error from the +call, since a listed method can be refused for a particular event or target. `deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and `deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants. @@ -227,10 +238,10 @@ adds `setRoom` only when it is posting into a room, as `ThreadCreate`, `Classifi Some routing is built into welshman. `Reactions.react` and `Deletes.deleteEvent` find the target's relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory, -so `RoomChat` and `publishRoomQuote` publish raw templates through `Thunks`. +so `RoomChat` and `publishRoomQuote` publish raw templates through `Publisher`. Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for -a room) and read results with `deriveEventsForUrl(url, filters)`, which uses the tracker to keep +a room) and read results with `$events.forUrl(url, filters)`, which uses the tracker to keep only events seen on that relay. A repository-wide query would mix rooms that share an `h` across relays. `src/app/sync.ts` pulls each space's room state, membership and recent content in the background; see flotilla-state. @@ -289,14 +300,13 @@ These predate the rule or are waiting on welshman. Don't copy them; fix one when - **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in `src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`); `STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push - subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE` + subscriptions (`PUSH_SUBSCRIPTION`, 30390, local to `src/app/push/adapters/capacitor.ts`); `DIRECT_MESSAGE_FILE` (15) in `Chat.svelte`. - **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`); relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in `ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62` (`VANISH`). -- **Getter exists but bypassed:** titles in `src/app/title.ts`, `NoteContentThread` and the - threads pages; calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response` +- **Getter exists but bypassed:** calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response` tags in `PollVotes`; `p` tags of `RELAY_ADD_MEMBER`, `ROOM_ADD_MEMBER` and `ROOM_CREATE_PERMISSION` (`SpaceMembersSummary`, `RoomItemAddMember`, `src/app/management.ts`); comment `E`/`A` tags in the list pages and `src/app/classifieds.ts`; `imeta` unpacking in diff --git a/.agents/skills/flotilla-model/kinds.md b/.agents/skills/flotilla-model/kinds.md index ffa97031..2a254319 100644 --- a/.agents/skills/flotilla-model/kinds.md +++ b/.agents/skills/flotilla-model/kinds.md @@ -10,14 +10,14 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`. |---|---|---|---|---| | Chat | `MESSAGE` (9) | none | `src/app/rooms.ts` | `spaces/[relay]/chat`, `spaces/[relay]/[h]` (`RoomChat`) | | Threads | `THREAD` (11) | `Thread` | none | `spaces/[relay]/threads`, `threads/[id]` (`ThreadCreate`) | -| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`) | `CommentCompose`, `EventReply` | +| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`), `src/app/rooms.ts` (`publishComment`) | `CommentCompose`, `EventReply`, `EventComments` | | Articles | `LONG_FORM` (30023) | `Article` | `src/app/articles.ts` | `spaces/[relay]/articles`, `articles/create`, `articles/[address]` | | Calendar | `EVENT_TIME` (31923) | `TimeEvent` | `src/app/feeds.ts` (`makeCalendarFeed`) | `spaces/[relay]/calendar`, `calendar/[address]` (`CalendarEventForm`) | | Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) | | Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) | | Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) | -| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member | -| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomPinnedMessagesAll` | +| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinSave`); published by any member | +| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomItemMenuMobile`, `RoomPinnedMessagesAll` | | Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay | | Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` | | Voice room participants | `LIVEKIT_PARTICIPANTS` (39004, defined in `src/app/call.ts`) | none | `src/app/call.ts` | rooms where `meta.hasLivekit()` | @@ -26,10 +26,10 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`. | Feature | Constant (kind) | Factory | Module | Where | |---|---|---|---|---| -| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, the `*Actions` components | +| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, `ContentActions`, `CommentActions`, `ArticleActionBar` | | Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS | -| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` | -| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` | +| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenu` | +| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenu` | ## Direct messages @@ -52,4 +52,4 @@ Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/syn | Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting | | Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` | | Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` | -| Push subscriptions | 30390 (a literal, no constant) | none | `src/app/push/adapters/capacitor.ts` | background | +| Push subscriptions | 30390 (`PUSH_SUBSCRIPTION`, local) | none | `src/app/push/adapters/capacitor.ts` | background | diff --git a/.agents/skills/flotilla-release/SKILL.md b/.agents/skills/flotilla-release/SKILL.md new file mode 100644 index 00000000..5749ad44 --- /dev/null +++ b/.agents/skills/flotilla-release/SKILL.md @@ -0,0 +1,89 @@ +--- +name: flotilla-release +description: "Use this skill when cutting, publishing, or debugging a flotilla release, or when changing anything under scripts/release, scripts/fdroid, scripts/desktop, fastlane/, fdroid/, zapstore.yaml or .gitea/workflows. It covers the version bump, changelog, tagging, the local and CI release runs, every distribution target (container image, gitea release and desktop update feed, Obtainium, zapstore, Google Play, App Store, F-Droid with reproducible builds, the GitHub mirror), the credentials each needs, and a release checklist with the mistakes that have bitten before." +--- + +# Releasing flotilla + +A release is one version tag and two runs against it. `pnpm release:local`, on a Mac, does everything that needs a signing key, so no key ever sits on the server. Pushing the tag starts `.gitea/workflows/release.yml`, which does everything that needs only a gitea token: the container image, the Linux and Windows desktop packages, and F-Droid's build. Both runs attach to the same draft gitea release, and whichever attaches the last required file publishes it. + +## Where each target ships from + +| target | built by | ships to | finished by | +| --- | --- | --- | --- | +| Web / self-hosting | `image` job in `release.yml` | `gitea.coracle.social/coracle/flotilla:` and `:latest` | automatic | +| Android APK | `apk` step, distribution key | `flotilla-.apk` on the gitea release | automatic | +| Obtainium | the gitea release (and its GitHub copy) | users' Obtainium, by source url | automatic | +| zapstore | `zapstore` step, `zsp` | zapstore relays | automatic | +| Google Play | `play` step, upload key | a `draft` release on the `production` track | rolling it out in Play Console | +| iOS | `ios` step | build uploaded and attached to the App Store version, with What's New | submitting for review in App Store Connect | +| macOS | `desktop` step on the Mac, signed and notarized | gitea release + `latest-mac.yml` | automatic | +| Linux, Windows | `desktop` step in CI (Windows in the `electronuserland/builder` container) | gitea release + `latest-linux.yml`, `latest.yml` | automatic | +| F-Droid | `fdroid` (CI) builds unsigned, `fdroid-sign` (local) signs | `flotilla-fdroid` generic package on gitea, which F-Droid verifies its own build against | F-Droid's bot, from the tag | +| GitHub mirror | `mirror.yml` | tags on push, the latest published release every 3 hours | automatic | + +Gitea's latest release is the desktop update feed, so a release stays a draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml` manifests. + +## Layout + +- `scripts/release/local.mjs` and `ci.mjs` list their steps; each step is a module in `scripts/release/steps/` with `missing()` (preflight), `setup` (how to fix it) and `run()`. +- `scripts/release/lib/pipeline.mjs` checks everything before anything runs: the tag exists, is pushed and is HEAD; `CHANGELOG.md` has a section for the version; the fastlane changelog matches it; `node_modules` matches `pnpm-lock.yaml`; and each step's credentials and tools. `--check` stops there. A failed step prints the command to resume from it. +- `lib/context.mjs` reads the version from `package.json`, the version code from `android/app/build.gradle`, and the notes from `CHANGELOG.md`. `shortNotes` is the notes cut at the last whole line under 500 characters, for Play and F-Droid. +- `scripts/release/bump.mjs` (`pnpm bump`) sets the version in `package.json`, Android and iOS, bumping each platform's build number only when its marketing version changes. +- `scripts/release/github.mjs` copies gitea's latest release to GitHub, run by `mirror.yml`. +- `scripts/fdroid/reproduce.sh` is F-Droid's build, run in their buildserver image; `fdroid/metadata/social.flotilla.fdroid.yml` is the recipe, mirrored in fdroiddata. +- `fastlane/metadata/android/en-US/` is F-Droid's listing, read from the tag. + +The steps are safe to rerun. `play` finishes a release from a bundle Play already has when the local AAB is byte-identical to it, `ios` reuses a build number App Store Connect already has and leaves an already-submitted version alone, `gitea` replaces same-named assets, and the GitHub copy skips files whose size matches. + +## Credentials + +Local ones go in `.env.local`; `pnpm release:check` names whatever is missing and how to get it. + +| variable | used by | +| --- | --- | +| `GITEA_TOKEN` (`write:repository`, `write:package`) | `gitea`, `fdroid-sign` | +| `ANDROID_KEYSTORE_*` | `apk`, `fdroid-sign`; the distribution key, which can never change | +| `PLAY_KEYSTORE_*`, `PLAY_SERVICE_ACCOUNT` | `play` | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | `ios`, and notarizing macOS | +| `CSC_NAME` | `desktop` on macOS (Developer ID Application certificate) | +| `SIGN_WITH` | `zapstore` | +| `DOCKER` | optional, e.g. `podman`, for container builds run from the Mac | + +CI uses the job's own token for the release, and the `PACKAGE_TOKEN` and `GH_MIRROR_TOKEN` secrets for the container registry and F-Droid package, and for GitHub. + +## Checklist + +### Before tagging + +- [ ] `git pull` on dev, then `pnpm install --frozen-lockfile` and `npm ci --prefix electron`. The release refuses stale dependencies; building against them once shipped Capacitor 8.3.4 native code with 8.5.2 Swift. +- [ ] `pnpm bump patch` (or `minor`, `major`, `x.y.z`). Always pass the argument. +- [ ] Write the `# ` section of `CHANGELOG.md`. The first ~500 characters are what Play and F-Droid show, so lead with what matters. +- [ ] `pnpm release:changelog` to write `fastlane/.../changelogs/.txt`. +- [ ] New F-Droid screenshots or listing text, if the UI changed, go in `fastlane/` now; F-Droid only reads them from the tag. +- [ ] If the F-Droid build environment changed (Node major in `.nvmrc`, the JDK, the fdroid scripts), update the recipe in `fdroid/metadata/` and open an fdroiddata merge request with the same change. +- [ ] `git add -A && git commit`, `pnpm release:check`, then `git tag ` and `git push origin dev `. + +### Releasing + +- [ ] Start `pnpm release:local` once the tag is pushed. `fdroid-sign` waits for CI's F-Droid build, up to two hours. +- [ ] Watch the Release workflow run for the tag in gitea's Actions tab. Its `image` and `release` jobs must both pass. +- [ ] When a step fails, fix the cause and rerun the command the run prints, which resumes from that step. For a CI step, push the fix to dev and run the Release workflow by hand from dev with the steps to rerun, e.g. `fdroid`; it builds the tag's commit with dev's scripts, so the tag never has to move. + +### After both runs + +- [ ] The gitea release is published, not a draft, with `flotilla-.apk`, the macOS DMGs and ZIPs, the AppImage, the Windows installer, and `latest.yml`, `latest-linux.yml`, `latest-mac.yml`. +- [ ] Play Console: review the draft on the production track and roll it out. +- [ ] App Store Connect: submit the version for review once the build is attached. +- [ ] The `flotilla-fdroid` package has `flotilla-fdroid-.apk` for this version. +- [ ] Within three hours, GitHub's latest release is this version, with the APK. +- [ ] Within a few days, F-Droid shows the version. A failed reproducibility check shows up in F-Droid's build logs for `social.flotilla.fdroid`. + +## Mistakes that have bitten before + +- **Moving a tag publishes its draft.** Force-pushing a tag when a draft release exists for it makes gitea clear the draft flag. It also restarts the Release workflow, which rebuilds the image and replaces the CI-built files. Once F-Droid has built a tag, never move it: F-Droid won't rebuild. +- **Version codes are single-use.** Play and App Store Connect never accept a version code or build number twice. When an upload of the wrong build is already there, bump `versionCode` in `android/app/build.gradle` or `CURRENT_PROJECT_VERSION` in the Xcode project, not the version. +- **The Play service account** needs its app permissions saved in Play Console, and new access can take up to a day to reach the API ("The caller does not have permission"). +- **`cap sync` rewrites native files** (`ios/App/Podfile`, `AndroidManifest.xml`, `capacitor.settings.gradle`) during `web`. On stale dependencies it points them at the wrong versions; revert them rather than committing. +- **Uploading a build is not releasing it.** Play stays a draft and iOS waits for review until someone acts on the follow-ups the run prints. +- **The F-Droid recipe lives in two places.** Our CI builds from `fdroid/metadata/`, F-Droid builds from fdroiddata. If they drift, our apk stops matching theirs and F-Droid won't ship it. diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index a1d5fbe1..46bbdb22 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -1,6 +1,6 @@ --- name: flotilla-state -description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)." +description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → publication, optimistic updates, undo, showing publish status)." --- # Flotilla state @@ -8,7 +8,7 @@ description: "Use this skill when deciding where a piece of state belongs in Flo State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in the current app's repository. Plugin indexes and derived stores read the repository, and components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the -command becomes a thunk, and the thunk writes its event into the repository before any relay has +command becomes a publication, and the `Publisher` writes its event into the repository before any relay has seen it. Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app. @@ -36,7 +36,7 @@ The app is therefore stable for as long as anything under the login gate is moun | `session` | the persisted `Session` | `undefined` | | `user` | `User.require($app)`, derived | subscribing or `.get()` throws | | `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe | -| `profiles`, `rooms`, `relays`, `thunks`, … | `usePlugin` for 27 welshman plugins | safe | +| `profiles`, `rooms`, `relays`, `events`, `publisher`, … | `usePlugin` for 28 welshman plugins | safe | | `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe | | `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` | | `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults | @@ -62,8 +62,8 @@ The following run outside that gate: - every app policy This code reads `app.get().user?.pubkey` and bails when it is missing, as -`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts` -do. +`syncUserSpaceMembership` in `src/app/sync.ts` and `syncCheckedRemote` in +`src/app/notifications.ts` do. Stores derived from `user` throw as soon as they are subscribed signed out. That includes `isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom` @@ -88,8 +88,9 @@ The rule is about when a binding is made: - **Module scope, and anything outside the gate**, goes through `app`, `usePlugin`, `fromApp` or `deriveUserItem`. A module-level `app.get()` builds the first app before the policies - register, and a binding made that way keeps reading the discarded app after login. Long-lived - listeners re-bind on `app.subscribe`, as `chatsById` in `src/app/chats.ts`, + register, and a binding made that way keeps reading the discarded app after login. A + long-lived listener over the repository belongs on a plugin, as in `Chats` (`chats.ts`), so + it is dropped with the app. Other listeners re-bind on `app.subscribe`, as `syncCheckedRemote` in `notifications.ts` and the resync in the root layout do. - **Code under the gate** can bind at call time. `rooms.get().forUrl(url).$` inside `deriveUserRooms`, or `$app.use(X)` in a component's script, is fine there, because the app @@ -105,7 +106,8 @@ Bookkeeping for one identity lives on a plugin instance, so it is discarded with `Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason. Module-level caches are fine when their contents do not depend on who is signed in, or when what -they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores. +they cache is itself a rebinding store. `deriveCommandsForUrl` (`commands.ts`) caches `fromApp` +stores with `simpleCache`. `hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from `@welshman/lib` to share one store per url across every component that asks. @@ -117,6 +119,11 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores. | `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey | | `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay | | `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s | +| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL | +| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` | +| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) | +| `GoalProgresses` (`goals.ts`) | none | one memoized NIP-75 progress store per goal and relay, behind `deriveGoalProgress` | +| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view | Each is exposed with `usePlugin`. `Statuses` is the minimal shape: @@ -170,20 +177,23 @@ app, and `app.get()` recurses into building another one. ## The repository and derived state Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then -`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing -optimistically. The tracker records which relays each event was seen on, which is what lets +`repository.publish`), from `Storage` loading the cache at startup, and from the `Publisher` +publishing optimistically. The tracker records which relays each event was seen on, which is what lets space content be keyed by relay. -`src/app/repository.ts` wraps the `@welshman/store` derivations in `fromApp`: +Raw event queries go through welshman's `Events` plugin, exported from `core.ts` as `events`. +Every method returns a projection over the current app's repository: -- `deriveEvent`, `deriveEvents`, `deriveEventsById`, `deriveIsDeleted` -- relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`, - `getEventsForUrl` -- `deriveLatestEvent` +- `one(idOrAddress, relays)` (loads when nothing local matches, keeps deleted events), `byId`, + `all`, `asc`, `desc`, `isDeleted` +- relay-scoped through the tracker: `forUrl`, `byIdForUrl`, `byIdByUrl`, and + `relaySignedForUrl`, which keeps only events signed by the relay's NIP-11 `self` -Use these for raw event queries. Welshman's `Events` plugin has the same surface returning -projections, and flotilla does not use it. Plugin reads (`get`, `one`, `load`, `index`) are -documented in welshman-app. +Components bind `$events.forUrl(url, filters).$`. A `derive*` function called from gated code +binds `events.get().forUrl(...).$` at call time, and a module-scope store wraps it in +`fromApp($app => $app.use(Events).byIdByUrl(filters).$)`, as `latestActivityByPath` does. +`src/app/repository.ts` holds only the `LatestEvents` plugin. Plugin reads (`get`, `one`, `load`, +`index`) are documented in welshman-app. ### Free functions in an app module @@ -195,14 +205,14 @@ projections and repository derivations: export const deriveSpaceActionItems = (url: string) => derived( [ - deriveEventsForUrl(url, [{kinds: [REPORT]}]), + events.get().forUrl(url, [{kinds: [REPORT]}]).$, rooms.get().pendingJoins(url).$, - deriveSpaceSupportedMethods(url), + deriveSpacePermissions(url), ], - ([$reports, $pendingJoins, $methods]) => + ([$reports, $pendingJoins, $permissions]) => sortEventsDesc([ - ...($methods.includes("banevent") ? $reports : []), - ...($methods.includes("allowpubkey") ? $pendingJoins : []), + ...($permissions.deleteContent ? $reports : []), + ...($permissions.addMembers ? $pendingJoins : []), ]), ) ``` @@ -220,14 +230,15 @@ Rules that involve more than one plugin belong in these functions rather than in A derivation that every row subscribes to, or that joins large sets, is built by hand: -- `chatsById` (`chats.ts`) updates incrementally from repository `update` events rather than - re-querying. -- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array - wherever an event's thunks are unchanged so rows don't churn. +- `Chats` (`chats.ts`) updates its `index` incrementally from repository `update` events rather + than re-querying. Its `search` reads profile names when it is rebuilt, so it also follows the + profiles index. +- `publicationsByEventId` (`publications.ts`) indexes publish history once, and hands back the + previous array wherever an event's publications are unchanged so rows don't churn. - `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and settings behind `throttled(1000, …)`. -- `deriveLatestEvent` (`repository.ts`) shares one repository listener across every watched - author. +- `LatestEvents.forPubkey` (`repository.ts`) shares one repository listener across every + watched author. ## Local and persisted state @@ -236,7 +247,7 @@ A derivation that every row subscribes to, or that joins large sets, is built by | repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted | | settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted | | `session`, `wallet` | `ss` | no | cleared | -| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state | `kv` | no | cleared | +| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state, `forceHealthChecks` | `kv` | no | cleared | | drafts, dictations | a module `Map`, lost on reload | no | page reloads | ### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`) @@ -262,21 +273,30 @@ Room messages, threads and other content are not kept, and background sync pulls next tick. To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin, -add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe -to `onItem`, and batch the writes. +add a `TABLES` entry and an `init*` method that calls `persistMapPlugin` with the table, the +plugin, and how to turn a row into a key and item and back. It loads the rows, subscribes to +`onItem`, and batches the writes. -### kv, ss and the two ways to bind them +### kv, ss and synced stores `kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from `storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so anything in them outlives a login and is cleared only by logout. Secrets go in `ss`. -- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the - default first, and the stored value arrives later (`.ready`). `theme` and `flTheme` - (`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it. -- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it - for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot - code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists. +Each persisted store is declared in its owner module with `synced({key, storage, defaultValue})`, +or `withGetter(synced(...))` when callers need `.get()`, which keeps `.ready`. The store emits the default first and loads the stored value in the background, so +`.ready` resolves once it has arrived: + +- `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`), + `device` (`device.ts`), `forceHealthChecks` (`healthChecks.ts`) in `kv` +- `pushState` (key `notificationState`) and `notificationSettings` in `kv`, both in + `push/adapters/common.ts` and re-exported from `@app/push` +- `wallet` (`lightning.ts`) in `ss` + +The root layout awaits `.ready` for the stores boot code reads (`device`, `shouldUnwrap`, +`wallet`, `notificationSettings`, `pushState`, `forceHealthChecks`) before `Push.sync()` and +background sync start. A new store that boot reads joins that list. Never change a key, since +it is where existing installs keep their value. Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme. @@ -295,7 +315,7 @@ Settings are an encrypted app-data event with d-tag `flotilla/settings`, read th A preference that should follow the user across devices goes in `SettingsValues` and `defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do -in `notificationSettings`. Per-space alert preferences are published (`alerts`); the device's +in `notificationSettings` (`@app/push`). Per-space alert preferences are published (`alerts`); the device's push permission is not. `checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to @@ -313,7 +333,7 @@ its composer has gone. ## Background sync (`src/app/sync.ts`) The root layout calls `syncApplicationData()` once the session is restored and storage is ready, -and again after every app swap. `Access.completeJoin` calls it after a space is joined. Each +and again after every app swap. `completeSpaceJoin` in `access.ts` calls it after a space is joined. Each call tears down the previous run. - `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's @@ -327,8 +347,9 @@ call tears down the previous run. - `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on. `pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through -an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against -the room list, so a new filter goes into the right `pullAndListen` call. +an `AbortController`. `syncUserData`, `syncSpaces` and `syncDMs` each keep one sync per url with +`makeKeyedSyncs`, whose `reconcile(urls)` starts the new ones and stops the dropped ones. A run +reads the pubkey once when it starts, since login triggers a fresh `syncApplicationData`. Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data that must be current app-wide belongs here. Data only one page shows is loaded by that page's @@ -336,7 +357,7 @@ components; see flotilla-views. ## Mutations -The prevailing path runs from a domain writer to a command to a thunk, adapted from +The prevailing path runs from a domain writer to a command to a publication, adapted from `ThreadCreate.svelte`: ```typescript @@ -350,8 +371,8 @@ if (room) { eventWriter.setRoom(url, room) } -const thunk = await command(eventWriter).then(publish) -const error = await thunk.waitForError() +const publication = await command(eventWriter).then(publish) +const error = (await publication.settled()).getError() if (error) { return pushToast({theme: "error", message: error}) @@ -368,44 +389,45 @@ Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then( `forceLoad` before writing. A replaceable event you build yourself needs the same, as in `publishSettings`. -Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that +Some call sites call `publisher.get().publish({event, relays, delay})` directly. Anything that honours the `send_delay` window does, because `Command` cannot carry it: room chat -(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and -`publishRoomQuote` in `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through -`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`). -NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return +(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in +`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through +`wraps.get().publish({event, recipients})`, which returns a `PublicationGroup` (see `reactions.ts`). +NIP-86 calls (`relayManagement.get().forUrl(url)`) don't go through the `Publisher`. They return `{result, error}`, and the caller handles `error`. ### Optimistic updates, undo and status -- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its +- **Optimistic writes.** `Publisher` writes the event into the repository and tracks it against its relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the - unsigned event for the signed one. -- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from - `history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts, - and a comment carries the same Cancel in the `ThunkPending` row under it. + unsigned event for the signed one, and `publication.event` follows it. +- **Undo.** `publication.abort()` while `canAbort()` holds (nothing has reached a relay yet) removes + the event from the repository and from `history`. When `send_delay` is set, room chat shows a + `PublicationToast` whose Cancel button aborts, and a comment carries the same Cancel in the + `PublicationPending` row under it. - **Editing.** Editing a message deletes it and republishes with the same `created_at` (see `RoomChat.svelte`). -- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass - `$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay. - `ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the - whole `history` per row instead. -- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt +- **Status in rows.** Rows look up `$publicationsByEventId.get(event.id) ?? noPublications` and + pass `$publisher.merge(...)` to `PublicationStatus`, or to `PublicationFailure`, which retries per + relay. A retry drops the publications it replaces from `history`, so the row shows the retry's + outcome. `PublicationStatusOrDeleted` combines publish status with deletion. +- **Status in forms.** Forms await `settled()`, read `getError()` and toast the message, as in the excerpt above. ## Other app-level stores - **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then - `allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two - stores, so the inbox matches the badges. + `allNotifications`, then `notifications` and the counts. The inbox stores + (`inboxConversations`, `inboxSpaceContent`) derive from the same two, so the inbox matches the + badges. - **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`, `currentCallSession`, …). - **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`. -- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are - classes a component instantiates (`new Access(url)`). They hold the writables and actions for - a multi-step flow. -- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the - root layout persists. +- **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates. + It holds the writables and actions for a multi-step flow. +- **Module-owned values.** `wallet` in `lightning.ts` persists itself to `ss` with + `withGetter(synced(...))`. ## Runes and stores @@ -423,7 +445,7 @@ store, consumed in components with `$store`. Take the first answer that fits: 1. **It is an event, or derived from events.** It is already in the repository, or should be. - Read it with a plugin or an `@app/repository` derivation, and put the domain logic in a + Read it with a plugin or the `events` projections, and put the domain logic in a `derive*` function in the owning app module (`deriveUserRooms`). Don't copy it into a writable. 2. **It is a keyed collection of one kind, loaded by key.** Write a plugin. A generic kind goes @@ -445,7 +467,7 @@ Take the first answer that fits: - `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance - `flotilla-views`: routes, components, and how components load data and show state - `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds -- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events` +- `welshman-app`: `App`, plugins, `Command`, `Publisher`, `Network`/`Sync`, `Events` - `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter` - `welshman-domain`: the readers and writers behind `reader`, `writer` and `command` - `welshman-net`: the repository, tracker and socket policies under the app diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md index a3364972..eef3cea1 100644 --- a/.agents/skills/flotilla-views/SKILL.md +++ b/.agents/skills/flotilla-views/SKILL.md @@ -56,7 +56,8 @@ const url = decodeRelay(relay) That is safe because the layouts remount their children when a param changes. `spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and -`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` keys on `?at=`, and +`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` and the space chat page key on +`?at=` and `?event=` (which is how a room transcript re-anchors on a permalink), and `people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`, or its page has to derive from `$page` instead. Routes read `$page` from the deprecated `$app/stores`; only the two modal modules use `page` from `$app/state`. @@ -99,7 +100,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves `makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`). - `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`, - `A` and `E` tags), and falls back to `entityLink` from `src/app/env.ts`, an external link. + `A` and `E` tags), and returns `undefined` for an event with no page in the app. A caller that + wants an external link then calls `entityLink` from `src/app/env.ts` itself, as + `makeEventPermalink` does. - `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute and navigates otherwise; `makeEventPermalink` is the shareable form. - `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which @@ -110,9 +113,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`. Without one the tab shows only `PLATFORM_NAME`. -Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`), -the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a -plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and +Deep links arrive in `handleDeepLink` in `src/app/deepLinks.ts`: push-notification links +(`?relay=&id=`), the iOS share extension (the `share` host), signer returns (`x-callback-url`), and +otherwise a plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and loads events before calling `goToEvent`. ### Adding a space content page @@ -187,15 +190,22 @@ behind its own loading state. Reusable confirmations get a `*Confirm` wrapper ### Popover menus `MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop -that hides it: +that hides it. It shows the menu-dots icon unless given children, opens `bottom-end` unless given a +`placement`, and takes `strayMargin` to hide once the pointer strays that many pixels from the +popover, as the hover actions on a chat row do: ```svelte ``` -`EventMenu` attaches `onClick` to its `