diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md new file mode 100644 index 00000000..5b7ec557 --- /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` (the space owner's 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..a379b596 --- /dev/null +++ b/.agents/skills/flotilla-model/SKILL.md @@ -0,0 +1,318 @@ +--- +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 | +| `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) | +| `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, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a +bare `deriveEventsForUrl`. + +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 +(`setFeaturedContent` in `src/app/featured.ts`) is published by that person and read back scoped to +them, so `deriveUserIsSpaceOwner(url)` is what shows the editor. + +The library is written by its members. A shelf or a pin is signed with the member's own key and +published to the space like any other space content, so the library reads every `PINBOARD` seen on +the relay rather than only the relay's own. Whoever signed a shelf is the only one who can edit or +delete it, and anyone can pin to it. + +## 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, keeping the half the +user holds a method for — reports under `banevent`, join requests under `allowpubkey`. + +`src/app/rooms.ts` puts space authority on top: + +- `deriveUserIsRoomAdmin`: a space's staff administer 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` | + +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. +`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. + +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 | +| `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`. + +## 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..ffa97031 --- /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 by any member | +| 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..a1d5fbe1 --- /dev/null +++ b/.agents/skills/flotilla-state/SKILL.md @@ -0,0 +1,451 @@ +--- +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` +- `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-`. `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)` 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 diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md new file mode 100644 index 00000000..a3364972 --- /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`, +`deriveSpaceSupportedMethods` 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.loadLenient`. + +**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 (` - {/snippet} - - - {#snippet label()} -

    Summary

    - {/snippet} - {#snippet input()} - - {/snippet} -
    - - {#snippet label()} -

    Topics

    - {/snippet} - {#snippet input()} - - {/snippet} -
    - - {#snippet label()} -

    Content*

    - {/snippet} - {#snippet input()} -
    - -
    - {/snippet} -
    - - - - - - - - diff --git a/src/app/components/ArticleItem.svelte b/src/app/components/ArticleItem.svelte index b88f4f34..f13d7273 100644 --- a/src/app/components/ArticleItem.svelte +++ b/src/app/components/ArticleItem.svelte @@ -4,33 +4,35 @@ import {getAddress} from "@welshman/util" import {Article} from "@welshman/domain" import Link from "@lib/components/Link.svelte" + import Cv from "@lib/components/Cv.svelte" import Content from "@app/components/Content.svelte" import ProfileLink from "@app/components/ProfileLink.svelte" import ArticleActions from "@app/components/ArticleActions.svelte" + import UnreadDot from "@app/components/UnreadDot.svelte" + import type {FeedContext} from "@app/feeds" import {reader} from "@app/core" import {makeArticlePath} from "@app/routes" type Props = { url: string event: TrustedEvent + context: FeedContext } - const {url, event}: Props = $props() + const {url, event, context}: Props = $props() - const article = reader(Article)(event) - - const title = article.title() - const summary = article.summary() - const image = article.image() + const path = $derived(makeArticlePath(url, getAddress(event))) + const article = $derived(reader(Article)(event)) + const title = $derived(article.title()) + const summary = $derived(article.summary()) + const image = $derived(article.image()) -
    + - + +
    {#if image} @@ -47,13 +49,14 @@ expandMode="inline" minLength={100} maxLength={300} /> -
    - +
    + Written by - +
    + +
    -
    +
    diff --git a/src/app/components/Banner.svelte b/src/app/components/Banner.svelte new file mode 100644 index 00000000..e035fc1f --- /dev/null +++ b/src/app/components/Banner.svelte @@ -0,0 +1,19 @@ + + + +
    + {@render children()} +
    diff --git a/src/app/components/BoardMenu.svelte b/src/app/components/BoardMenu.svelte index f9d84b5f..be90f00c 100644 --- a/src/app/components/BoardMenu.svelte +++ b/src/app/components/BoardMenu.svelte @@ -13,8 +13,7 @@ import EventInfo from "@app/components/EventInfo.svelte" import PinAdd from "@app/components/PinAdd.svelte" import PinboardEdit from "@app/components/PinboardEdit.svelte" - import {deletes} from "@app/core" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deletes, relays, user} from "@app/core" import {shareEvent} from "@app/share" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -27,8 +26,6 @@ const {url, board, onClick}: Props = $props() - const canManage = deriveUserIsSpaceAdmin(url) - const showInfo = () => pushModal(EventInfo, {url, event: board.event}) const share = () => shareEvent(url, "Shelf", board.event) @@ -39,8 +36,11 @@ const deleteBoard = async () => { try { - const command = await $deletes.deleteEvent(board.event) - const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError()) + const protect = await $relays.hasNip(url, 70) + const command = await $deletes.deleteEvent(board.event, writer => + writer.setProtected(protect), + ) + const error = await command.publishToRelays([url]).waitForError() if (error) { pushToast({theme: "error", message: error}) @@ -80,13 +80,13 @@ Share to chat - {#if $canManage} -
  • - -
  • +
  • + +
  • + {#if board.event.pubkey === $user.pubkey}
  • + + + diff --git a/src/app/components/CalendarEventActions.svelte b/src/app/components/CalendarEventActions.svelte index 5199fbc5..1323e2ea 100644 --- a/src/app/components/CalendarEventActions.svelte +++ b/src/app/components/CalendarEventActions.svelte @@ -1,6 +1,6 @@ + + + {#if continuesBefore} + → + {:else if start} + {formatTimestampAsTime(start)} + {/if} + {getEventTitle(event)} + {#if continuesAfter} + → + {/if} + diff --git a/src/app/components/CalendarEventCreate.svelte b/src/app/components/CalendarEventCreate.svelte index 9ffe8f33..0f7ba123 100644 --- a/src/app/components/CalendarEventCreate.svelte +++ b/src/app/components/CalendarEventCreate.svelte @@ -3,17 +3,22 @@ import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import CalendarEventForm from "@app/components/CalendarEventForm.svelte" + import {makeDayRange} from "@app/calendar" type Props = { url: string h?: string shareToChat?: boolean + date?: Date } - const {url, h, shareToChat = false}: Props = $props() + const {url, h, shareToChat = false, date}: Props = $props() + + // Creating from a day in the grid starts the event on that day + const range = date ? makeDayRange(date) : undefined - + {#snippet header()} Create an Event diff --git a/src/app/components/CalendarEventDiscussion.svelte b/src/app/components/CalendarEventDiscussion.svelte new file mode 100644 index 00000000..6c85b84c --- /dev/null +++ b/src/app/components/CalendarEventDiscussion.svelte @@ -0,0 +1,78 @@ + + +
    + {#if ordered.length === 0 && !showReply} +
    +

    No comments yet — start the conversation.

    + +
    + {:else} + {#if !showAll && ordered.length > 4} +
    + +
    + {/if} + {#each ordered.slice(0, showAll ? undefined : 4) as reply (reply.id)} + +
    + + +
    +
    + {/each} + {#if showReply} + + {:else} +
    + +
    + {/if} + {/if} +
    diff --git a/src/app/components/CalendarEventEdit.svelte b/src/app/components/CalendarEventEdit.svelte index 21d68118..0de7df47 100644 --- a/src/app/components/CalendarEventEdit.svelte +++ b/src/app/components/CalendarEventEdit.svelte @@ -14,7 +14,7 @@ const {url, event}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) const initialValues = $derived( timeEvent && { diff --git a/src/app/components/CalendarEventForm.svelte b/src/app/components/CalendarEventForm.svelte index 0fe81194..04dda91a 100644 --- a/src/app/components/CalendarEventForm.svelte +++ b/src/app/components/CalendarEventForm.svelte @@ -2,6 +2,7 @@ import type {Snippet} from "svelte" import {writable} from "svelte/store" import {randomId} from "@welshman/lib" + import {relay} from "@welshman/util" import {publish} from "@welshman/app" import {TimeEvent} from "@welshman/domain" import {preventDefault} from "@lib/html" @@ -38,9 +39,19 @@ shareToChat?: boolean header: Snippet initialValues?: Values + defaultStart?: number + defaultEnd?: number } - let {url, h, shareToChat = false, header, initialValues}: Props = $props() + let { + url, + h, + shareToChat = false, + header, + initialValues, + defaultStart, + defaultEnd, + }: Props = $props() const draftKey = new DraftKey(`calendar:${url}:${h ?? ""}`) @@ -57,7 +68,9 @@ const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const submit = async () => { - if ($uploading || loading) return + if ($uploading || loading) { + return + } if (!title) { return pushToast({ @@ -100,7 +113,7 @@ if (h) { eventWriter.setRoom(url, h) } else { - eventWriter.forceRelays(url) + eventWriter.forceRoutes(relay(url)) } const calendarThunk = await command(eventWriter).then(publish) @@ -114,7 +127,7 @@ history.back() if (shareToChat) { - publishRoomQuote({url, h, parent: calendarThunk.event, protect}) + publishRoomQuote({url, h, parent: calendarThunk.options.event, protect}) } pushToast({message: "Your event has been saved!"}) @@ -128,8 +141,9 @@ const d = $state(initialValues?.d ?? randomId()) let title = $state(initialValues?.title ?? "") let location = $state(initialValues?.location ?? "") - let start: number | undefined = $state(initialValues?.start) - let end: number | undefined = $state(initialValues?.end) + // A day chosen before the form opened is more current than whatever the draft was left on + let start: number | undefined = $state(defaultStart ?? initialValues?.start) + let end: number | undefined = $state(defaultEnd ?? initialValues?.end) let content = $state(initialValues?.content ?? "") const onChange = (json: object) => { diff --git a/src/app/components/CalendarEventHeader.svelte b/src/app/components/CalendarEventHeader.svelte index ce61ee43..dd812cb1 100644 --- a/src/app/components/CalendarEventHeader.svelte +++ b/src/app/components/CalendarEventHeader.svelte @@ -1,11 +1,11 @@ diff --git a/src/app/components/CalendarEventItem.svelte b/src/app/components/CalendarEventItem.svelte index 9afa25db..c12f15f1 100644 --- a/src/app/components/CalendarEventItem.svelte +++ b/src/app/components/CalendarEventItem.svelte @@ -2,25 +2,29 @@ import type {TrustedEvent} from "@welshman/util" import {getAddress, tagValue, tagSpec} from "@welshman/util" import Link from "@lib/components/Link.svelte" + import Cv from "@lib/components/Cv.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte" + import type {FeedContext} from "@app/feeds" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import ProfileLink from "@app/components/ProfileLink.svelte" import RoomLink from "@app/components/RoomLink.svelte" + import UnreadDot from "@app/components/UnreadDot.svelte" import {makeCalendarPath} from "@app/routes" type Props = { url: string event: TrustedEvent + context: FeedContext } - const {url, event}: Props = $props() + const {url, event, context}: Props = $props() - const h = tagValue(tagSpec("h"), event.tags) + const path = $derived(makeCalendarPath(url, getAddress(event))) + const h = $derived(tagValue(tagSpec("h"), event.tags)) - + +
    @@ -29,6 +33,6 @@ in {/if} - +
    - +
    diff --git a/src/app/components/CalendarEventPeople.svelte b/src/app/components/CalendarEventPeople.svelte new file mode 100644 index 00000000..e25a1f39 --- /dev/null +++ b/src/app/components/CalendarEventPeople.svelte @@ -0,0 +1,46 @@ + + +
    +
    +

    Host

    +
    + +
    +
    + {#each sections as section (section.label)} +
    +

    {section.label} · {section.pubkeys.length}

    + {#each section.pubkeys as pubkey (pubkey)} +
    + +
    + {/each} +
    + {:else} +

    + No one has responded yet — be the first. +

    + {/each} +
    diff --git a/src/app/components/CalendarMonth.svelte b/src/app/components/CalendarMonth.svelte new file mode 100644 index 00000000..5225a169 --- /dev/null +++ b/src/app/components/CalendarMonth.svelte @@ -0,0 +1,123 @@ + + +
    +
    + {#each weeks[0] as day (day.getTime())} +
    {formatWeekday(day)}
    + {/each} +
    + {#each weeks as week, weekIndex (week[0].getTime())} + {@const bars = weekBars[weekIndex]} +
    + {#each week as day, col (day.getTime())} + + {/each} + {#each bars as bar (bar.event.id)} +
    + +
    + {/each} + {#each week as day, col (day.getTime())} + {@const dayEvents = getSingleDayEvents(eventsByDay.get(makeDayKey(day)) ?? [])} +
    + {#each dayEvents.slice(0, 3) as event (event.id)} + + {/each} + {#if dayEvents.length > 3} + + {/if} +
    + {/each} +
    + {/each} +
    diff --git a/src/app/components/CalendarRsvp.svelte b/src/app/components/CalendarRsvp.svelte new file mode 100644 index 00000000..cc07d904 --- /dev/null +++ b/src/app/components/CalendarRsvp.svelte @@ -0,0 +1,106 @@ + + +
    +
    + Are you going? +
    + + + +
    + {#if loading} + + {/if} +
    + {#if attending.length > 0} + {@const summary = [ + `${people.accepted.length} going`, + people.tentative.length > 0 ? `${people.tentative.length} maybe` : undefined, + people.declined.length > 0 ? `${people.declined.length} can't go` : undefined, + ] + .filter(Boolean) + .join(" · ")} + + {#if onShowPeople} + + {:else} +
    + + {summary} +
    + {/if} + {/if} +
    diff --git a/src/app/components/CalendarWeek.svelte b/src/app/components/CalendarWeek.svelte new file mode 100644 index 00000000..ae3ff28d --- /dev/null +++ b/src/app/components/CalendarWeek.svelte @@ -0,0 +1,134 @@ + + + +
    +
    + {#each days as day (day.getTime())} + {@const dayEvents = eventsByDay.get(makeDayKey(day)) ?? []} +
    + + {#each dayEvents as event (event.id)} + + {:else} + — + {/each} +
    + {/each} +
    + +
    diff --git a/src/app/components/CallBanner.svelte b/src/app/components/CallBanner.svelte index 856b7ac8..c4d56b5a 100644 --- a/src/app/components/CallBanner.svelte +++ b/src/app/components/CallBanner.svelte @@ -1,6 +1,5 @@ {#if visible} -
    +
    -
  • + {/if} diff --git a/src/app/components/CallControlBar.svelte b/src/app/components/CallControlBar.svelte index df654cb2..1815a139 100644 --- a/src/app/components/CallControlBar.svelte +++ b/src/app/components/CallControlBar.svelte @@ -23,28 +23,27 @@ import {makeRoomPath} from "@app/routes" import { VideoCallLayout, - toggleCamera, - toggleScreenShare, videoCallLayout, CallState, currentCallSession, callTargetRoom, callMicMuted, callState, + } from "@app/call" + import { cancelJoinVoiceRoom, leaveVoiceRoom, + toggleCamera, toggleMute, - } from "@app/call" + toggleScreenShare, + } from "@app/callEngine" type Props = { url: string h: string - // suppress the full in-call control row on desktop — used where a wider, - // unobstructed copy of it already floats over the video panel - hideConnectedOnDesktop?: boolean } - const {url, h, hideConnectedOnDesktop = false}: Props = $props() + const {url, h}: Props = $props() const roomStore = $derived($rooms.forRoom(url, h)) const room = $derived($roomStore) @@ -67,6 +66,8 @@ const chatUnread = $derived($notifications.has(roomPath)) const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split) + const buttonClass = "button button-circle max-md:h-9 max-md:w-9" + const onChatToggle = () => { videoCallLayout.update(p => p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split, @@ -76,15 +77,13 @@ {#if joiningHere || connectedHere || showJoin} + place instead of pushing/jumping past each other as flex siblings. -->
    {#if joiningHere}
    + class="col-start-1 row-start-1 flex h-10 items-center gap-1.5 rounded-full border border-line bg-surface/95 px-1.5 shadow-xl backdrop-blur-md"> - +
    {/if} @@ -103,18 +102,12 @@
    + class="col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2"> @@ -124,7 +117,7 @@ aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"} aria-pressed={$currentCallSession.cameraOn} class={cx( - "button button-circle max-md:h-9 max-md:w-9", + buttonClass, $currentCallSession.cameraOn ? "button-primary" : "button-neutral", )} onclick={toggleCamera}> @@ -140,7 +133,7 @@ : "Share screen"} aria-pressed={$currentCallSession.screenShareOn} class={cx( - "button button-circle max-md:h-9 max-md:w-9", + buttonClass, $currentCallSession.screenShareOn ? "button-primary" : "button-neutral", )} onclick={toggleScreenShare}> @@ -151,7 +144,7 @@ @@ -160,7 +153,8 @@ aria-label="Toggle chat panel" aria-pressed={isChatPanelActive} class={cx( - "button button-circle relative max-md:h-9 max-md:w-9", + buttonClass, + "relative", isChatPanelActive ? "button-primary" : "button-neutral", )} onclick={onChatToggle}> @@ -175,17 +169,14 @@
    {/if} {#if showJoin} -
    +
    + {#if dictating || ($empty && !disabled)} + + {:else} + + {/if} diff --git a/src/app/components/ChatComposeEdit.svelte b/src/app/components/ChatComposeEdit.svelte deleted file mode 100644 index 5f212ea5..00000000 --- a/src/app/components/ChatComposeEdit.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -
    -

    Editing message

    - -
    diff --git a/src/app/components/ChatMenu.svelte b/src/app/components/ChatMenu.svelte index 806f6438..49db5445 100644 --- a/src/app/components/ChatMenu.svelte +++ b/src/app/components/ChatMenu.svelte @@ -1,43 +1,69 @@ - - -
    - +
    -
    -
    + + +
  • + +
  • +
diff --git a/src/app/components/ChatMessage.svelte b/src/app/components/ChatMessage.svelte index 2e65e34f..ac2c5f53 100644 --- a/src/app/components/ChatMessage.svelte +++ b/src/app/components/ChatMessage.svelte @@ -1,7 +1,7 @@
+ class={cx("group flex items-center justify-end gap-1 px-2", { + "flex-row-reverse": !isOwn, + "highlight-target": $highlightedEvent === event.id, + })}> {#if !isMobile} + props={{event, pubkeys, tippy, replyTo, edit}} + params={{interactive: true, trigger: "manual"}}>
- +
diff --git a/src/app/components/ChatMessageEmojiButton.svelte b/src/app/components/ChatMessageEmojiButton.svelte index c490c48a..8e34338d 100644 --- a/src/app/components/ChatMessageEmojiButton.svelte +++ b/src/app/components/ChatMessageEmojiButton.svelte @@ -20,6 +20,9 @@ } - + diff --git a/src/app/components/ChatMessageMenu.svelte b/src/app/components/ChatMessageMenu.svelte index 27f08ef6..6cf6ea27 100644 --- a/src/app/components/ChatMessageMenu.svelte +++ b/src/app/components/ChatMessageMenu.svelte @@ -8,13 +8,13 @@ import Reply from "@assets/icons/reply-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" - const {event, pubkeys, popover, replyTo, edit} = $props() + const {event, pubkeys, tippy, replyTo, edit} = $props() const reply = () => replyTo(event) const onEdit = () => edit?.() const showInfo = () => { - popover.hide() + tippy.hide() pushModal(EventInfo, {event}) } @@ -22,16 +22,19 @@
{#if replyTo} - {/if} {#if edit} - {/if} -
diff --git a/src/app/components/ChatMessageMenuMobile.svelte b/src/app/components/ChatMessageMenuMobile.svelte index 83d5f1a2..1dc3a7b9 100644 --- a/src/app/components/ChatMessageMenuMobile.svelte +++ b/src/app/components/ChatMessageMenuMobile.svelte @@ -6,9 +6,12 @@ import Reply from "@assets/icons/reply-2.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" + import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" + import AltArrowUp from "@assets/icons/alt-arrow-up.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Modal from "@lib/components/Modal.svelte" import ModalBody from "@lib/components/ModalBody.svelte" + import {slideAndFade} from "@lib/transition" import Button from "@lib/components/Button.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EventInfo from "@app/components/EventInfo.svelte" @@ -25,6 +28,8 @@ const {event, pubkeys, reply, edit}: Props = $props() + const tile = "button h-auto flex-col gap-1.5 py-4 text-xs" + const onEmoji = async (emoji: NativeEmoji) => { history.back() @@ -51,33 +56,49 @@ } const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true}) + + const toggleMore = () => { + showMore = !showMore + } + + let showMore = $state(false) - + -
- - -
+
+ - {#if edit} - + {#if showMore} +
+ {#if edit} + + {/if} + + +
{/if} -
diff --git a/src/app/components/ChatTabs.svelte b/src/app/components/ChatTabs.svelte new file mode 100644 index 00000000..498b8d0a --- /dev/null +++ b/src/app/components/ChatTabs.svelte @@ -0,0 +1,33 @@ + + +
+ {#each CHAT_TABS as { value, label } (value)} + + {/each} +
diff --git a/src/app/components/ClassifiedActions.svelte b/src/app/components/ClassifiedActions.svelte index e20cb30a..84db9948 100644 --- a/src/app/components/ClassifiedActions.svelte +++ b/src/app/components/ClassifiedActions.svelte @@ -1,8 +1,8 @@ -
- {#if h && showRoom} - - Posted in # - +
+ {#if (h && showRoom) || (showTopics && uniq(topics).length > 0)} +
+ {#if h && showRoom} + + Posted in # + + {/if} + {#if showTopics} + {#each uniq(topics) as topic (topic)} + + {/each} + {/if} +
+ {/if} -
- {#each uniq(topics) as topic (topic)} - - {/each} -
- + {#snippet status()} - + {#if showStatus} + + {/if} {/snippet} - + {#if showActivity} - + {/if} {#snippet customActions()} diff --git a/src/app/components/ClassifiedEdit.svelte b/src/app/components/ClassifiedEdit.svelte index 714a8b9a..5d7e5357 100644 --- a/src/app/components/ClassifiedEdit.svelte +++ b/src/app/components/ClassifiedEdit.svelte @@ -14,7 +14,7 @@ const {url, event}: Props = $props() - const classified = reader(Classified)(event) + const classified = $derived(reader(Classified)(event)) const initialValues = $derived( classified && { diff --git a/src/app/components/ClassifiedForm.svelte b/src/app/components/ClassifiedForm.svelte index a304383b..a3898f9a 100644 --- a/src/app/components/ClassifiedForm.svelte +++ b/src/app/components/ClassifiedForm.svelte @@ -1,10 +1,13 @@ - - {#if title} -
-

- {title} — - {price?.amount ?? 0} -

-

- {formatTimestamp(event.created_at)} -

-
- {:else} - - {/if} - -
- {#each images as image (image)} - - {/each} +
+ + {#if cover} + + {:else} +
+ +
+ {/if} + {#if isSold} + Sold + {/if} + {#if extraImages.length > 0} + + + {images.length} + + {/if}
-
- - Posted by - - {#if h} - in - {/if} +
+

{title}

+ {#if hasPrice} +

+ {price?.amount.toLocaleString()} +

+ {/if} + {#if summary} +

{summary}

+ {/if} + {#if topics.length > 0} +
+ {#each topics.slice(0, 2) as topic (topic)} + #{topic} + {/each} + {#if topics.length > 2} + +{topics.length - 2} + {/if} +
+ {/if} +
+
+ + + + + {formatTimestampRelative(event.created_at)} - +
- + diff --git a/src/app/components/ClassifiedStatus.svelte b/src/app/components/ClassifiedStatus.svelte index e81cea16..1369d6bc 100644 --- a/src/app/components/ClassifiedStatus.svelte +++ b/src/app/components/ClassifiedStatus.svelte @@ -11,13 +11,11 @@ const {event}: Props = $props() - const classified = reader(Classified)(event) - - const status = classified.status() + const status = $derived(reader(Classified)(event).status()) {#if status} - + {ucFirst(status)} {/if} diff --git a/src/app/components/CommandArgBar.svelte b/src/app/components/CommandArgBar.svelte new file mode 100644 index 00000000..0e001025 --- /dev/null +++ b/src/app/components/CommandArgBar.svelte @@ -0,0 +1,91 @@ + + +{#if draft} + +
+ + /{draft.command.command()} + {#each draft.args as arg, i (arg.name)} + + {displayArg(arg)} + + {/each} + + {#if invalid} + That doesn't look like a {invalid.type} + {:else} + {activeArg?.label || draft.command.description()} + {/if} +
+ {#if ambiguous} +
+ Several bots answer this — pick one: + {#each draft.matches as match (match.address())} + + {/each} +
+ {:else if choices.length > 0} +
+ {#each choices as choice (choice)} + + {/each} +
+ {/if} +
+{/if} diff --git a/src/app/components/CommentActions.svelte b/src/app/components/CommentActions.svelte index 90fc1589..505d9ee8 100644 --- a/src/app/components/CommentActions.svelte +++ b/src/app/components/CommentActions.svelte @@ -2,25 +2,23 @@ import type {TrustedEvent, EventContent} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util" import ReactionSummary from "@app/components/ReactionSummary.svelte" + import type {FeedContext} from "@app/feeds" import {publishReaction, retractReaction} from "@app/reactions" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import EventActivity from "@app/components/EventActivity.svelte" import EventActions from "@app/components/EventActions.svelte" - import {makeSpacePath} from "@app/routes" type Props = { url: string event: TrustedEvent - segment?: string showActivity?: boolean + context: FeedContext } - const {url, event, segment, showActivity = false}: Props = $props() + const {url, event, showActivity = false, context}: Props = $props() const h = tagValue(tagSpec("h"), event.tags) - const path = segment && makeSpacePath(url, segment, event.id) - const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) @@ -28,10 +26,16 @@
- - - {#if showActivity && path} - + + + {#if showActivity} + {/if} diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 63f1c200..700fe5ee 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -1,17 +1,18 @@ -
+
@@ -109,11 +146,11 @@
diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte index 427b6e5a..4047f292 100644 --- a/src/app/components/CommentTree.svelte +++ b/src/app/components/CommentTree.svelte @@ -1,49 +1,93 @@ -
- -
- - {#if url} -
- - -
- {/if} + +
+ {#if muted} +
+
+ +

You have muted this person.

+
+
- - {#if composing && url} -
+ {:else} +
+ +
+
+ + + {formatTimestamp(node.comment.created_at)} + +
+ +
+ {#if setReplyTo} + + {/if} + {#if url} + + {:else} + + {/if} +
+
+
+ {/if} + {#if composing} +
{/if} {#if node.children.length > 0} -
+ +
{#each node.children as child (child.comment.id)} - + {/each}
{/if} diff --git a/src/app/components/ComposeBar.svelte b/src/app/components/ComposeBar.svelte new file mode 100644 index 00000000..06c2330b --- /dev/null +++ b/src/app/components/ComposeBar.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children()} +
diff --git a/src/app/components/RoomComposeEdit.svelte b/src/app/components/ComposeEdit.svelte similarity index 64% rename from src/app/components/RoomComposeEdit.svelte rename to src/app/components/ComposeEdit.svelte index d432f3ff..9d84c401 100644 --- a/src/app/components/RoomComposeEdit.svelte +++ b/src/app/components/ComposeEdit.svelte @@ -1,8 +1,8 @@ -
+

Editing message

-
+ diff --git a/src/app/components/ComposeMenu.svelte b/src/app/components/ComposeMenu.svelte index 0fba2249..3520330f 100644 --- a/src/app/components/ComposeMenu.svelte +++ b/src/app/components/ComposeMenu.svelte @@ -8,10 +8,10 @@ import Revote from "@assets/icons/revote.svg?dataurl" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" - import {pushModal} from "@app/modal" + import {navigate, pushModal} from "@app/modal" + import {makeArticleCreatePath} from "@app/routes" import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte" - import ArticleCreate from "@app/components/ArticleCreate.svelte" import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte" import PollCreate from "@app/components/PollCreate.svelte" @@ -34,7 +34,7 @@ const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true}) - const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true}) + const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true})) let ul: Element diff --git a/src/app/components/ChatComposeParent.svelte b/src/app/components/ComposeParent.svelte similarity index 82% rename from src/app/components/ChatComposeParent.svelte rename to src/app/components/ComposeParent.svelte index b2c63bb4..faf966fa 100644 --- a/src/app/components/ChatComposeParent.svelte +++ b/src/app/components/ComposeParent.svelte @@ -1,10 +1,10 @@ -
+

{verb} @{$display}

{#key event.id} @@ -31,4 +28,4 @@ -
+ diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index 154919d4..b6e2e41f 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -1,5 +1,5 @@ + + + /{command.command()}{#if value.pubkey}@{/if} + diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index 05f9d1b7..20ff39d1 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -16,17 +16,23 @@ {#if isRoomOrRelay} - -{:else} + +{:else if isAudio} + +{:else if isVideo} - {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} - - {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} - - {:else} - {#await loadPreview(url)} -
- -
- {:then preview} -
- {#if preview.image && !hideImage} - - {/if} -
- {preview.title || displayUrl(url)} -

{ellipsize(preview.description, 140)}

-
-
- {:catch} -

- Unable to load a preview for {url} -

- {/await} - {/if} + +{:else if isImage} + + + +{:else} + {#await loadPreview(url)} + +
+
+ + {displayUrl(url)} + +
+
+ + {:then preview} + +
+ {#if preview.image && !hideImage} + + {/if} +
+ {preview.title || displayUrl(url)} +

{ellipsize(preview.description, 140)}

+
+
+ + {:catch} + + {/await} {/if} diff --git a/src/app/components/ContentLinkBlockAudio.svelte b/src/app/components/ContentLinkBlockAudio.svelte new file mode 100644 index 00000000..7c8e7617 --- /dev/null +++ b/src/app/components/ContentLinkBlockAudio.svelte @@ -0,0 +1,57 @@ + + +{#if failed} + {displayUrl(url)} +{:else if source} + +{:else} + +{/if} diff --git a/src/app/components/ContentLinkBlockImage.svelte b/src/app/components/ContentLinkBlockImage.svelte index 2225c6ec..271b2555 100644 --- a/src/app/components/ContentLinkBlockImage.svelte +++ b/src/app/components/ContentLinkBlockImage.svelte @@ -1,33 +1,20 @@ {#if hasError} - + {displayUrl(url)} diff --git a/src/app/components/ContentLinkInline.svelte b/src/app/components/ContentLinkInline.svelte index b8106c4b..da76ddc8 100644 --- a/src/app/components/ContentLinkInline.svelte +++ b/src/app/components/ContentLinkInline.svelte @@ -1,31 +1,31 @@ {#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} {displayUrl(url)} {:else} - + {/if} diff --git a/src/app/components/ContentLinkUrl.svelte b/src/app/components/ContentLinkUrl.svelte index d3f01540..64801c29 100644 --- a/src/app/components/ContentLinkUrl.svelte +++ b/src/app/components/ContentLinkUrl.svelte @@ -40,9 +40,15 @@ }) const [href, external] = call(() => { - if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false] - if (relayReference) return [makeSpacePath(relayReference), false] - if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false] + if (roomReference) { + return [makeRoomPath(roomReference.url, roomReference.h), false] + } + if (relayReference) { + return [makeSpacePath(relayReference), false] + } + if (url.startsWith(PLATFORM_URL)) { + return [url.replace(PLATFORM_URL, ""), false] + } return [url, true] }) diff --git a/src/app/components/ContentMarkdown.svelte b/src/app/components/ContentMarkdown.svelte index 9c490be9..73f04917 100644 --- a/src/app/components/ContentMarkdown.svelte +++ b/src/app/components/ContentMarkdown.svelte @@ -55,18 +55,19 @@ -
+
{@html html}
diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte index c09203f4..1d5694ca 100644 --- a/src/app/components/ContentMinimal.svelte +++ b/src/app/components/ContentMinimal.svelte @@ -48,8 +48,12 @@ const isBoundary = (i: number) => { const parsed = fullContent[i] - if (!parsed || isNewline(parsed)) return true - if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/)) + if (!parsed || isNewline(parsed)) { + return true + } + if (isText(parsed)) { + return Boolean(parsed.value.match(/^\s+$/)) + } return false } diff --git a/src/app/components/ContentQuote.svelte b/src/app/components/ContentQuote.svelte index 5fbf6188..8f77ee7f 100644 --- a/src/app/components/ContentQuote.svelte +++ b/src/app/components/ContentQuote.svelte @@ -7,6 +7,7 @@ Address, MESSAGE, eventOutbox, + fromNostrURI, getIdFilters, relays as relaySelections, seen, @@ -23,11 +24,13 @@ type Props = { value: any + raw: string event: TrustedEvent + inline?: boolean url?: string } - const {value, event, url}: Props = $props() + const {value, raw, event, inline = false, url}: Props = $props() const {id, identifier, kind, pubkey = value.author, relays = []} = value const idOrAddress = id || new Address(kind, pubkey, identifier).toString() @@ -53,7 +56,7 @@ mergedRelays.set(urls) if (!$quote) { - $network.load({filters: getIdFilters([idOrAddress]), relays: urls}) + $network.loadLenient({filters: getIdFilters([idOrAddress]), relays: urls}) } }) @@ -72,8 +75,14 @@ } - + + + + + + diff --git a/src/app/components/DictationButton.svelte b/src/app/components/DictationButton.svelte new file mode 100644 index 00000000..382097a9 --- /dev/null +++ b/src/app/components/DictationButton.svelte @@ -0,0 +1,178 @@ + + + diff --git a/src/app/components/EventActionBar.svelte b/src/app/components/EventActionBar.svelte new file mode 100644 index 00000000..f955e7cc --- /dev/null +++ b/src/app/components/EventActionBar.svelte @@ -0,0 +1,40 @@ + + +
+
+ + {@render leading?.()} +
+
+ {#if isRoot} + + {/if} +
+
diff --git a/src/app/components/EventActions.svelte b/src/app/components/EventActions.svelte index 6f3bce2e..95bbe332 100644 --- a/src/app/components/EventActions.svelte +++ b/src/app/components/EventActions.svelte @@ -1,20 +1,14 @@ +
- {#if ENABLE_ZAPS && !hideZap} - - - - {/if} - - - + +
+ +
+
+

+ {nodes.length === 0 + ? "Discussion about this post" + : `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`} +

+ {#if url && replyTo?.id !== event.id} + + {/if}
- {/if} -{/if} +
+
+
+ {#if url && replyTo?.id === event.id} +
+ +
+ {/if} + {#each nodes as node (node.comment.id)} + + {/each} + {#if nodes.length === 0 && replyTo?.id !== event.id} +

No comments yet.

+ {/if} +
+
+
diff --git a/src/app/components/EventInfo.svelte b/src/app/components/EventInfo.svelte index 2d50ce7c..53d3a9fe 100644 --- a/src/app/components/EventInfo.svelte +++ b/src/app/components/EventInfo.svelte @@ -100,7 +100,7 @@
{#each seenOn as url, i (url)} - {displayRelayUrl(url)} + {displayRelayUrl(url)} {/each}
diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte index 5790f75d..b15f4e42 100644 --- a/src/app/components/EventMenu.svelte +++ b/src/app/components/EventMenu.svelte @@ -9,17 +9,16 @@ import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl" import Button from "@lib/components/Button.svelte" - import Confirm from "@lib/components/Confirm.svelte" import Icon from "@lib/components/Icon.svelte" import EventInfo from "@app/components/EventInfo.svelte" import Report from "@app/components/Report.svelte" + import EventAdminDeleteConfirm from "@app/components/EventAdminDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte" import {shareEvent} from "@app/share" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveUserAdminDelete} from "@app/rooms" import {pushModal} from "@app/modal" - import {pushToast} from "@app/toast" - import {app, relayManagement, user} from "@app/core" + import {user} from "@app/core" type Props = { url: string @@ -32,7 +31,7 @@ const {url, noun, event, onClick, customActions}: Props = $props() const isRoot = event.kind !== COMMENT - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const adminDelete = deriveUserAdminDelete(url, event) const report = () => pushModal(Report, {url, event}) @@ -44,22 +43,7 @@ const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) - const showAdminDelete = () => - pushModal(Confirm, { - title: `Delete ${noun}`, - message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`, - confirm: async () => { - const {error} = await $relayManagement.forUrl(url).banEvent(event.id) - - if (error) { - pushToast({theme: "error", message: error}) - } else { - pushToast({message: "Event has successfully been deleted!"}) - $app.repository.removeEvent(event.id) - history.back() - } - }, - }) + const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event}) let ul: Element @@ -104,7 +88,7 @@ Report Content - {#if $userIsAdmin} + {#if $adminDelete}
  • + {/if} + {:else} +
    +

    + No one has contributed yet. Send the first zap and get top billing. +

    + {#if ENABLE_ZAPS && !progress.isEnded} + + + Be the first + + {/if} +
    + {/if} +
  • diff --git a/src/app/components/HealthCheckItem.svelte b/src/app/components/HealthCheckItem.svelte new file mode 100644 index 00000000..9d8d879d --- /dev/null +++ b/src/app/components/HealthCheckItem.svelte @@ -0,0 +1,29 @@ + + +
    +
    + {healthCheck.title} +

    {healthCheck.description}

    +
    + +
    diff --git a/src/app/components/HealthCheckReview.svelte b/src/app/components/HealthCheckReview.svelte new file mode 100644 index 00000000..dd254060 --- /dev/null +++ b/src/app/components/HealthCheckReview.svelte @@ -0,0 +1,93 @@ + + + + + + Review changes + Nothing changes until you confirm. + + {#each publishing as plan (plan.summary)} +
    +

    {plan.summary}

    + {#each plan.changes as change (change.label)} +
    + {change.label} + {#each change.added as url (url)} + + + {displayRelayUrl(url)} + + {/each} + {#each change.removed as url (url)} + + + {displayRelayUrl(url)} + + {/each} +
    + {/each} +
    + {/each} +
    + + + + +
    diff --git a/src/app/components/HomeActivity.svelte b/src/app/components/HomeActivity.svelte new file mode 100644 index 00000000..e8eb64f8 --- /dev/null +++ b/src/app/components/HomeActivity.svelte @@ -0,0 +1,35 @@ + + +{#if $inboxSpaceContent.length > 0} + +
    + {#each $inboxSpaceContent as { url, timestamp, countsByKind } (url)} + +
    + + +
    +
    + {#each [...countsByKind] as [kind, count] (kind)} + {displayContentCount(kind, count)} + {/each} +
    + {formatTimestamp(timestamp)} + + {/each} +
    +
    +{/if} diff --git a/src/app/components/HomeHealthChecks.svelte b/src/app/components/HomeHealthChecks.svelte new file mode 100644 index 00000000..d2e24744 --- /dev/null +++ b/src/app/components/HomeHealthChecks.svelte @@ -0,0 +1,43 @@ + + + + {#snippet action()} + {#if $pending.length > 0} + {$pending.length} open + {/if} + {/snippet} + {#if $pending.length === 0} +

    + + Your connection to the network looks healthy. +

    + {:else} +
    + {#each $pending as healthCheck (healthCheck.id)} + + {/each} + {#if $pending.length > 1} +
    + +
    + {/if} +
    + {/if} +
    diff --git a/src/app/components/HomeHosting.svelte b/src/app/components/HomeHosting.svelte new file mode 100644 index 00000000..3c19d35b --- /dev/null +++ b/src/app/components/HomeHosting.svelte @@ -0,0 +1,72 @@ + + + + {#snippet action()} + {#if relays.length > 0} + Manage + {/if} + {/snippet} + {#if loading} +
    + Checking your hosted spaces… +
    + {:else} + {#if relays.length > 0} +
    + {#each relays as relay (relay.id)} + + {/each} +
    + {/if} +
    +

    + {#if relays.length > 0} + Spin up another hosted space — rooms, calendar and moderation included. + {:else} + Don't wait for an invite. {PLATFORM_NAME} Hosting sets up a space with rooms, calendar and moderation + in about a minute. + {/if} +

    + +
    + {/if} +
    diff --git a/src/app/components/HomeInbox.svelte b/src/app/components/HomeInbox.svelte new file mode 100644 index 00000000..66ae95ec --- /dev/null +++ b/src/app/components/HomeInbox.svelte @@ -0,0 +1,52 @@ + + + + {#snippet action()} + {#if $allNotifications.size > 0} + + {/if} + {/snippet} + {#if conversations.length > 0} +
    + {#each conversations as conversation (conversation.path)} + + {/each} +
    + {:else} +
    +

    You're all caught up

    +

    + The rooms, space chats and direct messages you belong to show what's unread here. +

    +
    + + + Add a space + + +
    +
    + {/if} +
    diff --git a/src/app/components/HomeInboxItem.svelte b/src/app/components/HomeInboxItem.svelte new file mode 100644 index 00000000..8eee9bd3 --- /dev/null +++ b/src/app/components/HomeInboxItem.svelte @@ -0,0 +1,30 @@ + + +{#if conversation.url && conversation.h} + +{:else if conversation.url} + +{:else} + +{/if} diff --git a/src/app/components/HomeInboxItemCard.svelte b/src/app/components/HomeInboxItemCard.svelte new file mode 100644 index 00000000..d75ec4f2 --- /dev/null +++ b/src/app/components/HomeInboxItemCard.svelte @@ -0,0 +1,50 @@ + + + + {@render icon()} +
    +
    + {@render title()} + {@render subtitle()} +
    +
    +
    + + {#if event.pubkey === $user.pubkey} + You + {:else} + + {/if} + + : +
    +
    + +
    +
    +
    +
    + {formatTimestamp(event.created_at)} + +
    + diff --git a/src/app/components/HomeInboxItemChat.svelte b/src/app/components/HomeInboxItemChat.svelte new file mode 100644 index 00000000..865be043 --- /dev/null +++ b/src/app/components/HomeInboxItemChat.svelte @@ -0,0 +1,37 @@ + + + + {#snippet icon()} + + {/snippet} + {#snippet title()} + {#if others.length > 0} + + {#if others.length > 1} + and {others.length - 1} {others.length > 2 ? "others" : "other"} + {/if} + {:else} + Note to self + {/if} + {/snippet} + {#snippet subtitle()} + direct message + {/snippet} + diff --git a/src/app/components/HomeInboxItemRoom.svelte b/src/app/components/HomeInboxItemRoom.svelte new file mode 100644 index 00000000..d2b03762 --- /dev/null +++ b/src/app/components/HomeInboxItemRoom.svelte @@ -0,0 +1,28 @@ + + + + {#snippet icon()} + + {/snippet} + {#snippet title()} + + {/snippet} + {#snippet subtitle()} + + {/snippet} + diff --git a/src/app/components/HomeInboxItemSpaceChat.svelte b/src/app/components/HomeInboxItemSpaceChat.svelte new file mode 100644 index 00000000..d693b679 --- /dev/null +++ b/src/app/components/HomeInboxItemSpaceChat.svelte @@ -0,0 +1,26 @@ + + + + {#snippet icon()} + + {/snippet} + {#snippet title()} + Chat + {/snippet} + {#snippet subtitle()} + + {/snippet} + diff --git a/src/app/components/HomeNetwork.svelte b/src/app/components/HomeNetwork.svelte new file mode 100644 index 00000000..b0f3a862 --- /dev/null +++ b/src/app/components/HomeNetwork.svelte @@ -0,0 +1,111 @@ + + + +
    + {#if isEmpty} +
    +

    Follow a few people to fill this out

    +

    + Posts from the people you follow collect here. Spaces are a good place to find some. +

    + Browse spaces +
    + {:else if items.length === 0} +
    + Loading recent posts... +
    + {:else} + {#each items as event (event.id)} + + {/each} + {#if loading} +
    + +
    + {/if} + {/if} +
    +
    diff --git a/src/app/components/HomeNetworkItem.svelte b/src/app/components/HomeNetworkItem.svelte new file mode 100644 index 00000000..64de9797 --- /dev/null +++ b/src/app/components/HomeNetworkItem.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/src/app/components/HomeSection.svelte b/src/app/components/HomeSection.svelte new file mode 100644 index 00000000..a89fa602 --- /dev/null +++ b/src/app/components/HomeSection.svelte @@ -0,0 +1,26 @@ + + +
    +
    +

    + + {title} +

    + {@render action?.()} +
    + {@render children()} +
    diff --git a/src/app/components/IconPicker.svelte b/src/app/components/IconPicker.svelte index 8baa8400..aa7c8266 100644 --- a/src/app/components/IconPicker.svelte +++ b/src/app/components/IconPicker.svelte @@ -1,32 +1,9 @@
    -
    - {#each filteredIcons as icon (icon.url)} - - {/each} -
    + {#await loading} + Loading icons… + {:then} +
    + {#each filteredIcons as icon (icon.name)} + + {/each} +
    + {/await}
    diff --git a/src/app/components/IconPickerModal.svelte b/src/app/components/IconPickerModal.svelte index b4993151..acb3f38b 100644 --- a/src/app/components/IconPickerModal.svelte +++ b/src/app/components/IconPickerModal.svelte @@ -10,7 +10,7 @@ const {onSelect}: Props = $props() - + diff --git a/src/app/components/ImageInputModal.svelte b/src/app/components/ImageInputModal.svelte new file mode 100644 index 00000000..178d5ac4 --- /dev/null +++ b/src/app/components/ImageInputModal.svelte @@ -0,0 +1,107 @@ + + + select(url))}> + + + Add an image + Upload a file, or link to an image hosted somewhere else. + + +
    + + or + +
    + + {#snippet label()} +

    Image URL

    + {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} + {#if url && !isValid} + Please enter a valid http or https link. + {:else if failed} + That link could not be loaded as an image. + {:else} + The image will be linked from where it's hosted rather than re-uploaded. + {/if} + {/snippet} +
    + {#if isValid && !failed} +
    + Preview (failedUrl = url)} /> +
    + {/if} +
    + + + + +
    diff --git a/src/app/components/InfoZapperError.svelte b/src/app/components/InfoZapperError.svelte index ca9a80a3..2bb65824 100644 --- a/src/app/components/InfoZapperError.svelte +++ b/src/app/components/InfoZapperError.svelte @@ -1,4 +1,5 @@ @@ -28,7 +30,7 @@ Unable to Zap

    - Zapping isn't possible right now because + Zapping isn't possible right now because {#if $zapper} their zap receiver isn't correctly set up. {:else} diff --git a/src/app/components/KeyDownload.svelte b/src/app/components/KeyDownload.svelte index b30c0ea5..340daff8 100644 --- a/src/app/components/KeyDownload.svelte +++ b/src/app/components/KeyDownload.svelte @@ -66,7 +66,7 @@ const ncryptsec = encrypt(hexToBytes(secret), password) instructions = ` This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using - a password you chose when you signed up. + a password you chose when you backed up your key. ${sharedCopy} @@ -95,15 +95,15 @@ try { await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions)) + didDownload = true } catch (e) { // Dismissing the native share sheet rejects with "Share canceled" — leave // the flow gated so the user can try again rather than showing an error. if (!errorMessage(e).toLowerCase().includes("cancel")) { + console.error(e) pushToast({theme: "error", message: "We couldn't save your key. Please try again."}) } } - - didDownload = true } const onPasswordChange = () => { @@ -123,7 +123,7 @@ - Your Keys are Ready! + Backup your Key

    A cryptographic key pair has two parts: your public key identifies your diff --git a/src/app/components/Landing.svelte b/src/app/components/Landing.svelte index 0c738732..1e9e8afd 100644 --- a/src/app/components/Landing.svelte +++ b/src/app/components/Landing.svelte @@ -1,4 +1,6 @@ - + -

    Log in with Nostr

    + Log in with Nostr

    {PLATFORM_NAME} is built using the , which diff --git a/src/app/components/LogInBunker.svelte b/src/app/components/LogInBunker.svelte index 92f84af7..19306a29 100644 --- a/src/app/components/LogInBunker.svelte +++ b/src/app/components/LogInBunker.svelte @@ -55,7 +55,9 @@ const {loading, bunker} = controller const onSubmit = async () => { - if ($loading) return + if ($loading) { + return + } try { const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl($bunker) diff --git a/src/app/components/MenuSettings.svelte b/src/app/components/MenuSettings.svelte index caec223f..36e75e35 100644 --- a/src/app/components/MenuSettings.svelte +++ b/src/app/components/MenuSettings.svelte @@ -1,7 +1,7 @@ - +

    - +
    + + Profile + + @@ -35,7 +41,6 @@ {#if Capacitor.getPlatform() !== "ios"} @@ -43,28 +48,18 @@ {/if} - - Relays - - Hosting Content @@ -72,9 +67,9 @@
    - Theme + Theme / - About + About /
    diff --git a/src/app/components/MicLevelMeter.svelte b/src/app/components/MicLevelMeter.svelte index 5f1e77dc..292a1211 100644 --- a/src/app/components/MicLevelMeter.svelte +++ b/src/app/components/MicLevelMeter.svelte @@ -17,7 +17,9 @@ const t = track level = 0 - if (!t || error) return + if (!t || error) { + return + } let frame: number | undefined const context = new AudioContext() @@ -50,14 +52,18 @@ const NOISE_FLOOR = 0.03 const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR) smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5 - if (smoothed < 0.005) smoothed = 0 + if (smoothed < 0.005) { + smoothed = 0 + } level = smoothed frame = requestAnimationFrame(tick) } tick() return () => { - if (frame !== undefined) cancelAnimationFrame(frame) + if (frame !== undefined) { + cancelAnimationFrame(frame) + } context.close().catch(() => {}) } }) diff --git a/src/app/components/ModalContainer.svelte b/src/app/components/ModalContainer.svelte index 0935a7cc..87bc9a1c 100644 --- a/src/app/components/ModalContainer.svelte +++ b/src/app/components/ModalContainer.svelte @@ -1,53 +1,89 @@ -
    +
    {#if muted}
    @@ -51,7 +68,7 @@ {/if} {/if} diff --git a/src/app/components/NoteContentArticle.svelte b/src/app/components/NoteContentArticle.svelte index 9c7efe94..475d7ce6 100644 --- a/src/app/components/NoteContentArticle.svelte +++ b/src/app/components/NoteContentArticle.svelte @@ -6,10 +6,9 @@ const props: ComponentProps = $props() - const article = reader(Article)(props.event) - - const title = article.title() - const image = article.image() + const article = $derived(reader(Article)(props.event)) + const title = $derived(article.title()) + const image = $derived(article.image())
    diff --git a/src/app/components/NoteContentBoard.svelte b/src/app/components/NoteContentBoard.svelte index 9f09287d..e70b5fd2 100644 --- a/src/app/components/NoteContentBoard.svelte +++ b/src/app/components/NoteContentBoard.svelte @@ -6,7 +6,7 @@ const props: ComponentProps = $props() - const board = reader(Pinboard)(props.event) + const board = $derived(reader(Pinboard)(props.event))
    diff --git a/src/app/components/NoteContentClassified.svelte b/src/app/components/NoteContentClassified.svelte index fa13293a..7bf1d1ac 100644 --- a/src/app/components/NoteContentClassified.svelte +++ b/src/app/components/NoteContentClassified.svelte @@ -8,14 +8,13 @@ const props: ComponentProps = $props() - const classified = reader(Classified)(props.event) - - const title = classified.title() - const images = classified.images() ?? [] - const price = classified.price() + const classified = $derived(reader(Classified)(props.event)) + const title = $derived(classified.title()) + const images = $derived(classified.images() ?? []) + const price = $derived(classified.price()) -
    +
    {#if title}

    {title} — @@ -25,7 +24,7 @@ {#if props.event.content} {/if} -

    +
    {#each images as image, i (i + image)} {/each} diff --git a/src/app/components/NoteContentGoal.svelte b/src/app/components/NoteContentGoal.svelte index 795642a5..d5596ed3 100644 --- a/src/app/components/NoteContentGoal.svelte +++ b/src/app/components/NoteContentGoal.svelte @@ -10,10 +10,14 @@ const goal = reader(ZapGoal)(props.event) const title = goal.title() + const image = goal.image() const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
    + {#if image} + + {/if}

    {title}

    diff --git a/src/app/components/NoteContentMinimalArticle.svelte b/src/app/components/NoteContentMinimalArticle.svelte index 03ecd3b2..b17c4f03 100644 --- a/src/app/components/NoteContentMinimalArticle.svelte +++ b/src/app/components/NoteContentMinimalArticle.svelte @@ -6,7 +6,7 @@ const props: ComponentProps = $props() - const title = reader(Article)(props.event).title() + const title = $derived(reader(Article)(props.event).title()) {#if title} diff --git a/src/app/components/NoteContentMinimalBoard.svelte b/src/app/components/NoteContentMinimalBoard.svelte index c613d454..fef2f3a0 100644 --- a/src/app/components/NoteContentMinimalBoard.svelte +++ b/src/app/components/NoteContentMinimalBoard.svelte @@ -6,7 +6,7 @@ const props: ComponentProps = $props() - const board = reader(Pinboard)(props.event) + const board = $derived(reader(Pinboard)(props.event)) {board?.title() || "Untitled shelf"} diff --git a/src/app/components/NoteContentMinimalClassified.svelte b/src/app/components/NoteContentMinimalClassified.svelte index f99b3c72..c67031a7 100644 --- a/src/app/components/NoteContentMinimalClassified.svelte +++ b/src/app/components/NoteContentMinimalClassified.svelte @@ -6,9 +6,8 @@ const props: ComponentProps = $props() - const classified = reader(Classified)(props.event) - - const title = classified.title() + const classified = $derived(reader(Classified)(props.event)) + const title = $derived(classified.title()) {#if title} diff --git a/src/app/components/NoteContentMinimalEventTime.svelte b/src/app/components/NoteContentMinimalEventTime.svelte index c5aa1226..43e3181c 100644 --- a/src/app/components/NoteContentMinimalEventTime.svelte +++ b/src/app/components/NoteContentMinimalEventTime.svelte @@ -16,7 +16,7 @@ const end = $derived(parseInt(meta.end)) -
    +

    {meta.title || meta.name}

    {#if !isNaN(start) && !isNaN(end)} @@ -25,7 +25,7 @@ {@const isSingleDay = startDateDisplay === endDateDisplay}
    - + {formatTimestampAsTime(start)} — {isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)} diff --git a/src/app/components/NoteContentMinimalGoal.svelte b/src/app/components/NoteContentMinimalGoal.svelte index 2579a384..9efaca24 100644 --- a/src/app/components/NoteContentMinimalGoal.svelte +++ b/src/app/components/NoteContentMinimalGoal.svelte @@ -1,16 +1,11 @@
    {title}
    - {zapAmount}/{goalAmount} sats funded + {$progress.raised.toLocaleString()}/{$progress.target.toLocaleString()} sats funded
    diff --git a/src/app/components/NoteContentMinimalPin.svelte b/src/app/components/NoteContentMinimalPin.svelte index daaed07e..aee914ba 100644 --- a/src/app/components/NoteContentMinimalPin.svelte +++ b/src/app/components/NoteContentMinimalPin.svelte @@ -6,7 +6,7 @@ const props: ComponentProps = $props() - const loadPin = reader(Pin)(props.event) + const loadPin = $derived(reader(Pin)(props.event)) {#await loadPin then pin} diff --git a/src/app/components/NoteContentPin.svelte b/src/app/components/NoteContentPin.svelte index f176b117..bb441f0c 100644 --- a/src/app/components/NoteContentPin.svelte +++ b/src/app/components/NoteContentPin.svelte @@ -9,7 +9,7 @@ const props: ComponentProps = $props() - const loadPin = reader(Pin)(props.event) + const loadPin = $derived(reader(Pin)(props.event)) {#await loadPin then pin} diff --git a/src/app/components/NoteDetail.svelte b/src/app/components/NoteDetail.svelte new file mode 100644 index 00000000..e4646768 --- /dev/null +++ b/src/app/components/NoteDetail.svelte @@ -0,0 +1,81 @@ + + + + + {#if $event} + {@const note = $event} + {@const replyToNote = () => setReplyTo(note)} +
    + + {#if replyTo?.id !== note.id} + + {/if} + + {#if replyTo?.id === note.id} + + {/if} +
    +
    + {#each $replies as node (node.comment.id)} + + {/each} + {#if $replies.length === 0} +

    No replies yet.

    + {/if} +
    + {:else} +
    + {#await sleep(5000)} + Loading note... + {:then} +

    Failed to load note.

    + {/await} +
    + {/if} +
    +
    diff --git a/src/app/components/NoteItem.svelte b/src/app/components/NoteItem.svelte index dc80d5d3..4aba21a3 100644 --- a/src/app/components/NoteItem.svelte +++ b/src/app/components/NoteItem.svelte @@ -1,44 +1,38 @@ - - +{#snippet body()} +
    - - - - - + {@render children?.()}
    -
    +{/snippet} + +{#if card} + + {@render body()} + +{:else} + + {@render body()} + +{/if} diff --git a/src/app/components/OpenRouterEnable.svelte b/src/app/components/OpenRouterEnable.svelte new file mode 100644 index 00000000..d3b00e06 --- /dev/null +++ b/src/app/components/OpenRouterEnable.svelte @@ -0,0 +1,98 @@ + + + + + + {action}? + {subtitle} + +

    + {feature} is powered by OpenRouter, which charges a fraction of a cent for each use. To turn it on, add some credit to an + OpenRouter account, then + create an API key and paste it below. +

    + + {#snippet label()} + OpenRouter API Key + {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} + Your key is stored in your {PLATFORM_NAME} settings, encrypted to your nostr identity. + {/snippet} + +
    + + + + +
    diff --git a/src/app/components/PeopleItem.svelte b/src/app/components/PeopleItem.svelte index 1c6fc5b2..8f71d675 100644 --- a/src/app/components/PeopleItem.svelte +++ b/src/app/components/PeopleItem.svelte @@ -2,6 +2,7 @@ import UserCircle from "@assets/icons/user-circle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Link from "@lib/components/Link.svelte" + import Cv from "@lib/components/Cv.svelte" import Profile from "@app/components/Profile.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte" @@ -15,7 +16,7 @@ const {pubkey, url}: Props = $props() -
    +
    @@ -29,4 +30,4 @@ View Profile -
    +
    diff --git a/src/app/components/PinAdd.svelte b/src/app/components/PinAdd.svelte index 7813bab7..137205f8 100644 --- a/src/app/components/PinAdd.svelte +++ b/src/app/components/PinAdd.svelte @@ -1,8 +1,9 @@ {#if $event} - + {:else}

    Loading event... diff --git a/src/app/components/PinEdit.svelte b/src/app/components/PinEdit.svelte index 5822de3d..45967d54 100644 --- a/src/app/components/PinEdit.svelte +++ b/src/app/components/PinEdit.svelte @@ -1,9 +1,10 @@

    @@ -22,7 +26,8 @@ class={cx("card card-sm card-interactive flex h-full w-full flex-col items-start gap-1 pr-10", { "card-primary": selected, })}> - {board.title() || "Untitled shelf"} + {board.title() || "Untitled shelf"} {#if board.description()} {board.description()} {/if} diff --git a/src/app/components/PinboardSelect.svelte b/src/app/components/PinboardSelect.svelte index 90ed42d9..226739e1 100644 --- a/src/app/components/PinboardSelect.svelte +++ b/src/app/components/PinboardSelect.svelte @@ -1,7 +1,9 @@ - + +
    @@ -29,6 +33,6 @@ in {/if} - +
    - +
    diff --git a/src/app/components/PollVotes.svelte b/src/app/components/PollVotes.svelte index f335797e..30355ed2 100644 --- a/src/app/components/PollVotes.svelte +++ b/src/app/components/PollVotes.svelte @@ -2,7 +2,7 @@ import {onDestroy} from "svelte" import {formatTimestampRelative} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {POLL_RESPONSE, tagSpec, tagValues} from "@welshman/util" + import {POLL_RESPONSE, tagSpec, tagValues, relay} from "@welshman/util" import {Poll, PollResponse} from "@welshman/domain" import type {Thunk} from "@welshman/app" import PollOption from "@app/components/PollOption.svelte" @@ -38,7 +38,7 @@ activeThunk = undefined if (selection.length > 0) { - const eventWriter = writer(PollResponse).forceRelays(url).setPollId(event.id) + const eventWriter = writer(PollResponse).forceRoutes(relay(url)).setPollId(event.id) for (const id of selection) { eventWriter.addSelection(id) diff --git a/src/app/components/PrimaryNav.svelte b/src/app/components/PrimaryNav.svelte index ce1679c5..54473070 100644 --- a/src/app/components/PrimaryNav.svelte +++ b/src/app/components/PrimaryNav.svelte @@ -2,22 +2,24 @@ import type {Snippet} from "svelte" import cx from "classnames" import {Profiles} from "@welshman/app" + import Compass from "@assets/icons/compass.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl" - import Widget from "@assets/icons/widget-4.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl" + import Sidebar from "@assets/icons/sidebar-minimalistic.svg?dataurl" import ImageIcon from "@lib/components/ImageIcon.svelte" import Divider from "@lib/components/Divider.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import MenuSettings from "@app/components/MenuSettings.svelte" - import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte" - import {userSpaceUrls} from "@app/rooms" + import Search from "@app/components/Search.svelte" + import SpaceMenuDrawer from "@app/components/SpaceMenuDrawer.svelte" import {PLATFORM_RELAYS} from "@app/env" - import {pushModal} from "@app/modal" + import {getModal, popModal, pushModal} from "@app/modal" import {notifications} from "@app/notifications" - import {goToChat, makeSpacePath} from "@app/routes" + import {userSpaceUrls} from "@app/rooms" + import {goToChat, lastSpaceUrl, makeSpacePath} from "@app/routes" import {deriveUserItem} from "@app/core" type Props = { @@ -32,12 +34,28 @@ const showSettingsMenu = () => pushModal(MenuSettings) - const anySpaceNotifications = $derived( - $userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))), + const showSearch = () => pushModal(Search) + + // The menu is reachable from every page, so it opens on the space the user is in, or the last one + // they were in when they're somewhere else. + const spaceUrl = $derived($lastSpaceUrl ?? PLATFORM_RELAYS[0] ?? $userSpaceUrls[0]) + + const spaceMenuIsOpen = $derived(getModal()?.component === SpaceMenuDrawer) + + const spaceMenuLabel = $derived(spaceMenuIsOpen ? "Close space menu" : "Open space menu") + + const toggleSpaceMenu = () => + spaceMenuIsOpen ? popModal() : pushModal(SpaceMenuDrawer, {url: spaceUrl}, {drawer: true}) + + const otherSpaceNotifications = $derived( + $userSpaceUrls.some(url => url !== spaceUrl && $notifications.has(makeSpacePath(url))), ) -
    + @@ -68,26 +91,25 @@
    + class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-(--mobile-nav-height) border border-line bg-surface md:hidden">
    - {#if PLATFORM_RELAYS.length === 1} - - {:else} - - + {#if spaceUrl} + + {/if} + + + - {#if PLATFORM_RELAYS.length !== 1} - - - - {/if}
    - + {#if $userProfile?.picture()} {:else} diff --git a/src/app/components/PrimaryNavItemSpace.svelte b/src/app/components/PrimaryNavItemSpace.svelte index 9cca2c04..30e1087f 100644 --- a/src/app/components/PrimaryNavItemSpace.svelte +++ b/src/app/components/PrimaryNavItemSpace.svelte @@ -1,8 +1,10 @@ - - -
    +
    {#each PLATFORM_RELAYS as url (url)} {:else} - - {#each primarySpaceUrls as url (url)} - - {/each} - {#snippet allSpaces(title: string)} - - - - {/snippet} - {#if secondarySpaceUrls.length > 0} - - {@render allSpaces("")} - - {:else} - {@render allSpaces("All Spaces")} - {/if} + +
    + + {#snippet item(url)} + + {/snippet} + +
    {/each}
    diff --git a/src/app/components/PrimaryNavSpacesOverflow.svelte b/src/app/components/PrimaryNavSpacesOverflow.svelte deleted file mode 100644 index c434b866..00000000 --- a/src/app/components/PrimaryNavSpacesOverflow.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
    - {#each urls as url (url)} - - {/each} -
    diff --git a/src/app/components/ProfileBadges.svelte b/src/app/components/ProfileBadges.svelte index 45cac751..208d77a9 100644 --- a/src/app/components/ProfileBadges.svelte +++ b/src/app/components/ProfileBadges.svelte @@ -1,11 +1,11 @@
    - {#if $events.length > 0} + {#if $latest} {/if} {#if $spaceUrls.length > 0} diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 131de3e5..f6c40b6f 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -1,11 +1,11 @@ - +
    {#snippet customActions()} - {#if $userIsAdmin} - {#if isBanned} + {#if !isSelf} +
  • + +
  • + {/if} + {#if isBanned} + {#if canAllow}
  • - {:else} + {/if} + {:else} + {#if canUnallow}
  • + {/if} + {#if canBan}
  • @@ -119,8 +144,10 @@ {/snippet}
    + +
    diff --git a/src/app/components/ProfileEditForm.svelte b/src/app/components/ProfileEditForm.svelte index 09117a42..3d49db38 100644 --- a/src/app/components/ProfileEditForm.svelte +++ b/src/app/components/ProfileEditForm.svelte @@ -48,7 +48,10 @@ let file: File | undefined = $state() - + {#if isSignup}
    diff --git a/src/app/components/ProfileFeed.svelte b/src/app/components/ProfileFeed.svelte index 836052dc..812f5e70 100644 --- a/src/app/components/ProfileFeed.svelte +++ b/src/app/components/ProfileFeed.svelte @@ -1,5 +1,5 @@ - diff --git a/src/app/components/ProfileMenu.svelte b/src/app/components/ProfileMenu.svelte index 5b6c82ca..258be1c6 100644 --- a/src/app/components/ProfileMenu.svelte +++ b/src/app/components/ProfileMenu.svelte @@ -1,9 +1,10 @@
    + + {#if website} - import {onMount} from "svelte" - import {derived, writable} from "svelte/store" - import type {Writable} from "svelte/store" + import {onDestroy, onMount} from "svelte" + import {writable} from "svelte/store" + import type {Readable} from "svelte/store" import {sortBy, uniqBy, now} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import {NOTE, outbox} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {getReplyTags} from "@welshman/domain" - import {PinLists} from "@welshman/app" import {fly} from "@lib/transition" import Spinner from "@lib/components/Spinner.svelte" import NoteItem from "@app/components/NoteItem.svelte" - import {app, network, router} from "@app/core" - import {makeFeed} from "@app/feeds" + import {router} from "@app/core" + import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" + import {derivePinnedEvents} from "@app/pins" type Props = { pubkey: string @@ -20,33 +21,27 @@ const {pubkey}: Props = $props() const relays = $router.resolver.relays([outbox(pubkey)]) - const pinnedIds = derived($app.use(PinLists).one(pubkey), $pinList => $pinList?.ids() ?? []) + const context = makeFeedContext({relays}) - $effect(() => { - if ($pinnedIds.length > 0) { - const controller = new AbortController() + onDestroy(context.cleanup) - relays.then($relays => - $network.load({ - relays: $relays, - filters: [{ids: $pinnedIds}], - signal: controller.signal, - onEvent: e => events.update($events => uniqBy(e => e.id, $events.concat(e))), - }), - ) + const pinnedEvents = derivePinnedEvents(pubkey) - return () => controller.abort() - } - }) + const pinnedIds = $derived($pinnedEvents.map(e => e.id)) let element: HTMLElement | undefined = $state() - let exhausted = $state(false) - let events: Writable = $state(writable([])) + let older: Maybe> = $state() + + const exhausted = $derived($older?.status === "exhausted") + const loading = $derived(isFeedLoading($older)) + let events: Readable = $state(writable([])) const feedEvents = $derived( sortBy( - e => ($pinnedIds.includes(e.id) ? -(now() + e.created_at) : -e.created_at), - $events.filter(e => getReplyTags(e.tags).replies.length === 0), + e => (pinnedIds.includes(e.id) ? -(now() + e.created_at) : -e.created_at), + uniqBy(e => e.id, [...$pinnedEvents, ...$events]).filter( + e => getReplyTags(e.tags).replies.length === 0, + ), ), ) @@ -56,15 +51,18 @@ relays.then($relays => { const feed = makeFeed({ relays: $relays, - element: element!, filters: [{kinds: [NOTE], authors: [pubkey]}], - onBackwardExhausted: () => { - exhausted = true - }, + onEvent: context.add, }) events = feed.events - cleanup = feed.cleanup + + older = makeScrollLoader(element!, feed.loadOlder) + + cleanup = () => { + older?.stop() + feed.cleanup() + } }) return () => cleanup?.() @@ -74,14 +72,14 @@
    {#each feedEvents as event (event.id)}
    - +
    {:else} {#if exhausted}

    No notes found for this profile.

    {/if} {/each} - {#if !exhausted} + {#if loading}

    Loading notes... diff --git a/src/app/components/ProfilePinnedNote.svelte b/src/app/components/ProfilePinnedNote.svelte new file mode 100644 index 00000000..1b42c7de --- /dev/null +++ b/src/app/components/ProfilePinnedNote.svelte @@ -0,0 +1,31 @@ + + +{#if event} +

    +

    + + Pinned note +

    + + + +
    +{/if} diff --git a/src/app/components/ProfileQrCode.svelte b/src/app/components/ProfileQrCode.svelte index 63a504af..b827c3c4 100644 --- a/src/app/components/ProfileQrCode.svelte +++ b/src/app/components/ProfileQrCode.svelte @@ -27,7 +27,9 @@ const copyCode = () => clip(code) const shareProfile = async () => { - if (!canShare) return + if (!canShare) { + return + } try { await Share.share({url: code}) @@ -48,7 +50,7 @@ }) - +
    Share Profile diff --git a/src/app/components/ProfileStatus.svelte b/src/app/components/ProfileStatus.svelte new file mode 100644 index 00000000..937a95cc --- /dev/null +++ b/src/app/components/ProfileStatus.svelte @@ -0,0 +1,34 @@ + + +{#if text} +
    + + {#if link} + {text} + {:else} + {text} + {/if} +
    +{/if} diff --git a/src/app/components/QRCode.svelte b/src/app/components/QRCode.svelte index af21fe0e..aec6e104 100644 --- a/src/app/components/QRCode.svelte +++ b/src/app/components/QRCode.svelte @@ -1,5 +1,4 @@ diff --git a/src/app/components/Reaction.svelte b/src/app/components/Reaction.svelte index 05cfd9e4..56f3b634 100644 --- a/src/app/components/Reaction.svelte +++ b/src/app/components/Reaction.svelte @@ -14,15 +14,17 @@ {#if event.content === "+" || event.content === ""} - + {:else if event.content === "-"} - + {:else} - {#each parse(event) as parsed, i (i)} - {#if isEmoji(parsed)} - - {:else} - {@html renderAsHtml(parsed)} - {/if} - {/each} + + {#each parse(event) as parsed, i (i)} + {#if isEmoji(parsed)} + + {:else} + {@html renderAsHtml(parsed)} + {/if} + {/each} + {/if} diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index f20b4d5d..e13ff0d4 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -1,12 +1,10 @@ -{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children} +{#if $reactions.length > 0 || (!hideZaps && $zaps.length > 0) || $reports.length > 0 || children}
    - {#if url && $reports.length > 0 && $userIsAdmin} + {#if url && $reports.length > 0 && canBanEvent} {/if} - {#each groupedZaps.entries() as [key, zaps] (key)} - {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} - {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} - {@const isOwn = pubkeys.includes($user.pubkey)} - {@const info = displayList( - pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()), - )} - {@const tooltip = `${info} zapped`} - {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} - - {/each} + {#if !hideZaps} + {#each groupedZaps.entries() as [key, zaps] (key)} + {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} + {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} + {@const isOwn = pubkeys.includes($user.pubkey)} + {@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))} + {@const tooltip = `${info} zapped`} + {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} + + {/each} + {/if} {#each groupedReactions.entries() as [key, events] (key)} {@const pubkeys = events.map(e => e.pubkey)} {@const isOwn = pubkeys.includes($user.pubkey)} - {@const info = displayList( - pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()), - )} + {@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))} {@const tooltip = `${info} reacted`} {@const onClick = () => onReactionClick(events, pubkeys, info)} -
    -
    - diff --git a/src/app/components/RecentItem.svelte b/src/app/components/RecentItem.svelte deleted file mode 100644 index 5ec1e9af..00000000 --- a/src/app/components/RecentItem.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -{#if item.type === "message"} - -{:else if item.event.kind === THREAD} - -{:else if item.event.kind === CLASSIFIED} - -{:else if item.event.kind === LONG_FORM} - -{:else if item.event.kind === ZAP_GOAL} - -{:else if item.event.kind === EVENT_TIME} - -{:else if item.event.kind === POLL} - -{:else} - -{/if} diff --git a/src/app/components/RelayAdd.svelte b/src/app/components/RelayAdd.svelte index aeb0604f..08702362 100644 --- a/src/app/components/RelayAdd.svelte +++ b/src/app/components/RelayAdd.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {SvelteSet} from "svelte/reactivity" import type {Readable} from "svelte/store" - import {tryCatch} from "@welshman/lib" + import {tryCatch, uniq} from "@welshman/lib" import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util" import type {Thunk} from "@welshman/app" import {Relays} from "@welshman/app" @@ -18,7 +18,7 @@ import ModalFooter from "@lib/components/ModalFooter.svelte" import RelayItem from "@app/components/RelayItem.svelte" import {pushToast} from "@app/toast" - import {app, blockedRelayLists, user} from "@app/core" + import {app, blockedRelayLists, relayLists, user} from "@app/core" interface Props { relays: Readable @@ -62,17 +62,33 @@ $relaySearch .searchValues(term) .filter((url: string) => { - if (matchRelay?.(url) === false) return false - if ($relays.includes(url)) return false - if (isIPAddress(url)) return false - if ($blockedRelayUrls.includes(url)) return false + if (matchRelay?.(url) === false) { + return false + } + if ($relays.includes(url)) { + return false + } + if (isIPAddress(url)) { + return false + } + if ($blockedRelayUrls.includes(url)) { + return false + } return true }) .slice(0, limit), ) + // Suggestions come from relay search, which only knows about relays whose NIP-11 + // document has been fetched — so seed it from everyone's relay lists. onMount(() => { + const urls = uniq($relayLists.all.get().flatMap(list => list.urls())) + + for (const url of urls.filter(isShareableRelayUrl)) { + $app.use(Relays).load(url) + } + const scroller = createScroller({ element: element!, delay: 300, @@ -87,7 +103,7 @@ }) - +