From fe5c11b00f8394940497a2e9cc6d42518ea62a7f Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 18 Jun 2026 19:31:14 +0000 Subject: [PATCH] rename client, update docs/skills --- docs/.vitepress/config.mts | 17 +- docs/app/app.md | 173 ++++ docs/app/commands.md | 76 -- docs/app/context.md | 13 - docs/app/data.md | 184 ++++ docs/app/feeds-and-search.md | 77 ++ docs/app/index.md | 131 +-- docs/app/making-requests.md | 147 ---- docs/app/plugins.md | 157 ++++ docs/app/publishing-events.md | 76 -- docs/app/publishing.md | 117 +++ docs/app/relay-selection.md | 56 -- docs/app/requests.md | 69 ++ docs/app/routing.md | 79 ++ docs/app/session.md | 210 ----- docs/app/tags.md | 46 - docs/app/user.md | 177 ++-- docs/app/wot.md | 78 +- packages/app/__tests__/tags.test.ts | 271 ------ packages/app/__tests__/thunk.test.ts | 119 --- packages/app/package.json | 4 +- .../{client/src/client.ts => app/src/app.ts} | 28 +- packages/app/src/blockedRelayLists.ts | 42 - packages/app/src/blossom.ts | 40 - packages/app/src/commands.ts | 378 -------- packages/app/src/context.ts | 5 - packages/app/src/core.ts | 5 - packages/app/src/createApp.ts | 14 + packages/app/src/feeds.ts | 44 - packages/app/src/follows.ts | 33 - packages/app/src/handles.ts | 150 ---- packages/app/src/index.ts | 144 +--- packages/{client => app}/src/logging.ts | 2 +- packages/app/src/messagingRelayLists.ts | 43 - packages/app/src/mutes.ts | 49 -- packages/app/src/pins.ts | 33 - packages/app/src/plaintext.ts | 44 - packages/{client => app}/src/plugins/base.ts | 14 +- .../src/plugins/blockedRelayLists.ts | 20 +- .../{client => app}/src/plugins/blossom.ts | 8 +- packages/{client => app}/src/plugins/feeds.ts | 30 +- .../{client => app}/src/plugins/follows.ts | 16 +- .../{client => app}/src/plugins/handles.ts | 16 +- .../src/plugins/messagingRelayLists.ts | 20 +- packages/{client => app}/src/plugins/mutes.ts | 26 +- .../{client => app}/src/plugins/network.ts | 24 +- packages/{client => app}/src/plugins/pins.ts | 16 +- .../{client => app}/src/plugins/plaintext.ts | 4 +- .../{client => app}/src/plugins/profiles.ts | 12 +- .../{client => app}/src/plugins/relayLists.ts | 34 +- .../src/plugins/relayManagement.ts | 8 +- .../{client => app}/src/plugins/relayStats.ts | 8 +- .../{client => app}/src/plugins/relays.ts | 0 packages/{client => app}/src/plugins/rooms.ts | 8 +- packages/app/src/plugins/router.ts | 29 + .../{client => app}/src/plugins/search.ts | 22 +- .../src/plugins/searchRelayLists.ts | 20 +- .../{client => app}/src/plugins/stores.ts | 28 +- packages/{client => app}/src/plugins/sync.ts | 14 +- packages/{client => app}/src/plugins/tags.ts | 30 +- packages/{client => app}/src/plugins/thunk.ts | 44 +- .../{client => app}/src/plugins/topics.ts | 10 +- packages/{client => app}/src/plugins/wot.ts | 40 +- packages/{client => app}/src/plugins/wraps.ts | 28 +- .../{client => app}/src/plugins/zappers.ts | 14 +- packages/{client => app}/src/policy.ts | 88 +- packages/app/src/profiles.ts | 43 - packages/app/src/relayLists.ts | 87 -- packages/app/src/relayStats.ts | 244 ------ packages/app/src/relays.ts | 73 -- packages/app/src/search.ts | 110 --- packages/app/src/searchRelayLists.ts | 36 - packages/app/src/session.ts | 366 ++------ packages/app/src/sync.ts | 47 - packages/app/src/tags.ts | 131 --- packages/app/src/thunk.ts | 431 --------- packages/app/src/topics.ts | 49 -- packages/app/src/user.ts | 131 +-- packages/app/src/wot.ts | 101 --- packages/app/src/zappers.ts | 156 ---- packages/app/tsconfig.build.json | 1 + packages/client/.eslintignore | 3 - packages/client/package.json | 51 -- packages/client/src/createApp.ts | 14 - packages/client/src/index.ts | 34 - packages/client/src/plugins/router.ts | 29 - packages/client/src/session.ts | 82 -- packages/client/src/user.ts | 57 -- packages/client/tsconfig.build.json | 20 - packages/client/tsconfig.json | 3 - skills/README.md | 2 +- skills/welshman-app/SKILL.md | 816 +++++------------- 92 files changed, 1811 insertions(+), 5268 deletions(-) create mode 100644 docs/app/app.md delete mode 100644 docs/app/commands.md delete mode 100644 docs/app/context.md create mode 100644 docs/app/data.md create mode 100644 docs/app/feeds-and-search.md delete mode 100644 docs/app/making-requests.md create mode 100644 docs/app/plugins.md delete mode 100644 docs/app/publishing-events.md create mode 100644 docs/app/publishing.md delete mode 100644 docs/app/relay-selection.md create mode 100644 docs/app/requests.md create mode 100644 docs/app/routing.md delete mode 100644 docs/app/session.md delete mode 100644 docs/app/tags.md delete mode 100644 packages/app/__tests__/tags.test.ts delete mode 100644 packages/app/__tests__/thunk.test.ts rename packages/{client/src/client.ts => app/src/app.ts} (78%) delete mode 100644 packages/app/src/blockedRelayLists.ts delete mode 100644 packages/app/src/blossom.ts delete mode 100644 packages/app/src/commands.ts delete mode 100644 packages/app/src/context.ts delete mode 100644 packages/app/src/core.ts create mode 100644 packages/app/src/createApp.ts delete mode 100644 packages/app/src/feeds.ts delete mode 100644 packages/app/src/follows.ts delete mode 100644 packages/app/src/handles.ts rename packages/{client => app}/src/logging.ts (94%) delete mode 100644 packages/app/src/messagingRelayLists.ts delete mode 100644 packages/app/src/mutes.ts delete mode 100644 packages/app/src/pins.ts delete mode 100644 packages/app/src/plaintext.ts rename packages/{client => app}/src/plugins/base.ts (94%) rename packages/{client => app}/src/plugins/blockedRelayLists.ts (79%) rename packages/{client => app}/src/plugins/blossom.ts (82%) rename packages/{client => app}/src/plugins/feeds.ts (60%) rename packages/{client => app}/src/plugins/follows.ts (78%) rename packages/{client => app}/src/plugins/handles.ts (86%) rename packages/{client => app}/src/plugins/messagingRelayLists.ts (79%) rename packages/{client => app}/src/plugins/mutes.ts (75%) rename packages/{client => app}/src/plugins/network.ts (71%) rename packages/{client => app}/src/plugins/pins.ts (77%) rename packages/{client => app}/src/plugins/plaintext.ts (84%) rename packages/{client => app}/src/plugins/profiles.ts (85%) rename packages/{client => app}/src/plugins/relayLists.ts (84%) rename packages/{client => app}/src/plugins/relayManagement.ts (69%) rename packages/{client => app}/src/plugins/relayStats.ts (96%) rename packages/{client => app}/src/plugins/relays.ts (100%) rename packages/{client => app}/src/plugins/rooms.ts (86%) create mode 100644 packages/app/src/plugins/router.ts rename packages/{client => app}/src/plugins/search.ts (83%) rename packages/{client => app}/src/plugins/searchRelayLists.ts (79%) rename packages/{client => app}/src/plugins/stores.ts (55%) rename packages/{client => app}/src/plugins/sync.ts (79%) rename packages/{client => app}/src/plugins/tags.ts (80%) rename packages/{client => app}/src/plugins/thunk.ts (87%) rename packages/{client => app}/src/plugins/topics.ts (85%) rename packages/{client => app}/src/plugins/wot.ts (83%) rename packages/{client => app}/src/plugins/wraps.ts (72%) rename packages/{client => app}/src/plugins/zappers.ts (92%) rename packages/{client => app}/src/policy.ts (52%) delete mode 100644 packages/app/src/profiles.ts delete mode 100644 packages/app/src/relayLists.ts delete mode 100644 packages/app/src/relayStats.ts delete mode 100644 packages/app/src/relays.ts delete mode 100644 packages/app/src/search.ts delete mode 100644 packages/app/src/searchRelayLists.ts delete mode 100644 packages/app/src/sync.ts delete mode 100644 packages/app/src/tags.ts delete mode 100644 packages/app/src/thunk.ts delete mode 100644 packages/app/src/topics.ts delete mode 100644 packages/app/src/wot.ts delete mode 100644 packages/app/src/zappers.ts delete mode 100644 packages/client/.eslintignore delete mode 100644 packages/client/package.json delete mode 100644 packages/client/src/createApp.ts delete mode 100644 packages/client/src/index.ts delete mode 100644 packages/client/src/plugins/router.ts delete mode 100644 packages/client/src/session.ts delete mode 100644 packages/client/src/user.ts delete mode 100644 packages/client/tsconfig.build.json delete mode 100644 packages/client/tsconfig.json diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ade7774..eab0e9c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -24,16 +24,15 @@ export default defineConfig({ text: "@welshman/app", link: "/app/", items: [ - {text: "Session Management", link: "/app/session"}, - {text: "Relay Selection", link: "/app/relay-selection"}, - {text: "Making Requests", link: "/app/making-requests"}, - {text: "Publishing Events", link: "/app/publishing-events"}, - {text: "Tag utilities", link: "/app/tags"}, + {text: "The App", link: "/app/app"}, + {text: "User & Sessions", link: "/app/user"}, + {text: "Plugin Architecture", link: "/app/plugins"}, + {text: "Data Plugins", link: "/app/data"}, + {text: "Publishing Events", link: "/app/publishing"}, + {text: "Making Requests", link: "/app/requests"}, + {text: "Routing & Tags", link: "/app/routing"}, {text: "Web of Trust", link: "/app/wot"}, - {text: "Storage", link: "/app/storage"}, - {text: "Context", link: "/app/context"}, - {text: "Commands", link: "/app/commands"}, - {text: "User", link: "/app/user"}, + {text: "Feeds & Search", link: "/app/feeds-and-search"}, ], }, { diff --git a/docs/app/app.md b/docs/app/app.md new file mode 100644 index 0000000..cf2fc3e --- /dev/null +++ b/docs/app/app.md @@ -0,0 +1,173 @@ +# The App + +An `App` is an application instance. It owns every piece of per-identity state and is the entry point to all features. You will usually create one with `createApp` and access everything else through `app.use(...)`. + +## Creating an app + +### `createApp(options?)` + +The batteries-included factory. It returns an `App` wired with the [default policies](#policies) (event ingestion, relay-stats collection, gift-wrap unwrapping, and NIP-42 auth) unless you pass your own `policies`. + +```typescript +import {createApp} from "@welshman/app" + +const app = createApp({ + user, // optional signed-in User + config: { + dufflepudUrl: "https://dufflepud.example", + getDefaultRelays: () => ["wss://relay.example"], + getIndexerRelays: () => ["wss://purplepag.es"], + getSearchRelays: () => ["wss://relay.nostr.band"], + }, +}) +``` + +### `new App(options?)` + +Use the constructor directly when you want a bare app with **no** side effects (for example in tests, or when you install policies yourself). + +```typescript +import {App} from "@welshman/app" + +const app = new App() // no policies installed +``` + +## `AppOptions` + +```typescript +type AppOptions = { + user?: User // the signed-in identity (at most one) + config?: AppConfig + getAdapter?: AdapterFactory // net-layer adapter factory + policies?: AppPolicy[] // side effects to install at construction +} +``` + +## `AppConfig` + +App-level configuration. All fields are optional; the three relay getters return `string[]` and feed the [Router](./routing). + +```typescript +type AppConfig = { + dufflepudUrl?: string // optional dufflepud service (batches NIP-05 / zapper lookups) + getDefaultRelays?: () => string[] + getIndexerRelays?: () => string[] // relays used to discover relay lists / profiles + getSearchRelays?: () => string[] // NIP-50 search relays +} +``` + +## `IApp` + +Plugins and policies never depend on the concrete `App` class — they take the `IApp` contract: + +```typescript +interface IApp { + user?: User + config: AppConfig + use: (Ctor: new (app: IApp) => T) => T + netContext: NetContext // {pool, repository, getAdapter} for the net layer + pool: Pool // connection pool + tracker: Tracker // tracks which relays have seen each event + repository: Repository // the local event store / single source of truth + wrapManager: WrapManager // NIP-59 gift-wrap bookkeeping +} +``` + +Every primitive (`pool`, `tracker`, `repository`, `wrapManager`) is constructed fresh per instance, so data never bleeds across identities or sessions. + +## Resolving features: `use` + +```typescript +use: (Ctor: new (app: IApp) => T) => T +``` + +`use` is a per-app singleton resolver. The first time you pass a plugin class, the app constructs `new Ctor(this)` and caches it; subsequent calls return the same instance. + +```typescript +const profiles = app.use(Profiles) +const sameInstance = app.use(Profiles) // identical reference +``` + +This is dependency resolution by demand. Plugins reach their own dependencies the same way (`this.app.use(Network)`, `this.app.use(Router)`), which means dependency cycles resolve lazily and there is no constructor wiring to maintain. + +## Teardown: `cleanup` + +```typescript +app.cleanup() +``` + +`cleanup()` runs every policy's unsubscribe function, then clears the `pool`, `tracker`, `repository`, and `wrapManager`. Call it when you discard an app (e.g. switching identities) to release connections and free memory. + +## Policies + +A **policy** is the unit of side effects. It runs once at construction and returns an `Unsubscriber` that `cleanup()` will later call. Keeping side effects in policies leaves the data plugins pure and centralizes teardown. + +```typescript +type AppPolicy = (app: IApp) => Unsubscriber +``` + +### Default policies + +`createApp` installs `defaultAppPolicies`: + +| Policy | What it does | +|---|---| +| `appPolicyIngest` | Subscribes to the pool; verifies inbound relay events (skipping DVM/ephemeral kinds) and writes them to the `repository` and `tracker`. This is how every repository-backed store gets populated. | +| `appPolicyRelayStats` | Pipes socket activity into the [`RelayStats`](./routing#relay-quality) store. | +| `appPolicyWraps` | Enqueues existing and newly-arriving gift-wrap events for unwrapping. | +| `appPolicyAuthUnlessBlocked` | Answers NIP-42 AUTH challenges, except for relays in the user's blocked-relay list. | + +### Auth policy builders + +```typescript +makeAppPolicyAuth(shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy + +appPolicyAuthNever // never answer AUTH +appPolicyAuthAlways // always answer AUTH +appPolicyAuthUnlessBlocked // answer unless the relay is blocked by the user +``` + +Auth policies are no-ops when there is no signed-in user. + +### Customizing policies + +Pass your own `policies` array to opt out of, or extend, the defaults: + +```typescript +import {App, defaultAppPolicies, makeAppPolicyLogger} from "@welshman/app" + +const app = new App({ + user, + policies: [ + ...defaultAppPolicies, + makeAppPolicyLogger(msg => console.log(msg)), // see Logging + ], +}) +``` + +## Logging + +`@welshman/app` can make a user's signer observable. `User.fromSigner`/`User.fromSession` wrap the underlying signer in a `LoggingSigner`, which emits a structured `LogMessage` for every signer operation (pending → success/failure). + +```typescript +type LogMessage = + | {type: "signer"; id: string; method: string; status: "pending" | "success" | "failure"; error?: unknown; at: number} + | {type: string; at: number; [key: string]: unknown} +``` + +Forward those messages by installing `makeAppPolicyLogger`: + +```typescript +import {makeAppPolicyLogger} from "@welshman/app" + +const app = new App({ + user, + policies: [...defaultAppPolicies, makeAppPolicyLogger(msg => { + if (msg.type === "signer" && msg.status === "failure") { + console.error("signing failed", msg.method, msg.error) + } + })], +}) +``` + +The logger policy is a no-op unless the user's signer is a `LoggingSigner` (which it is when the user was created via `User.fromSigner`/`User.fromSession`). diff --git a/docs/app/commands.md b/docs/app/commands.md deleted file mode 100644 index 69f28b7..0000000 --- a/docs/app/commands.md +++ /dev/null @@ -1,76 +0,0 @@ -# Commands - -Commands are functions which pull from app state to publish events on behalf of the user. Most are async and return a thunk - -## Relay Management (NIP 65) - -```typescript -removeRelay(url: string, mode: RelayMode): Promise -addRelay(url: string, mode: RelayMode): Promise -``` - -## Messaging Relay Management (NIP 17) - -```typescript -removeMessagingRelay(url: string): Promise -addMessagingRelay(url: string): Promise -``` - -## Profile Management (NIP 01) - -```typescript -setProfile(profile: Profile): Thunk -``` - -## Follow Management (NIP 02) - -```typescript -unfollow(value: string): Promise -follow(tag: string[]): Promise -``` - -## Mute Management - -```typescript -unmute(value: string): Promise -mutePublicly(tag: string[]): Promise -mutePrivately(tag: string[]): Promise -setMutes(options: { - publicTags?: string[][] - privateTags?: string[][] -}): Promise -``` - -## Pin Management - -```typescript -unpin(value: string): Promise -pin(tag: string[]): Promise -``` - -## Wrapped Messages (NIP 59) - -```typescript -type SendWrappedOptions = Omit & { - event: EventTemplate - recipients: string[] -} - -sendWrapped(options: SendWrappedOptions): Promise -``` - -## Relay Management (NIP 86) - -```typescript -manageRelay(url: string, request: ManagementRequest): Promise -``` - -## Room Management (NIP 29) - -```typescript -createRoom(url: string, room: RoomMeta): Thunk -deleteRoom(url: string, room: RoomMeta): Thunk -editRoom(url: string, room: RoomMeta): Thunk -joinRoom(url: string, room: RoomMeta): Thunk -leaveRoom(url: string, room: RoomMeta): Thunk -``` diff --git a/docs/app/context.md b/docs/app/context.md deleted file mode 100644 index 4926ad6..0000000 --- a/docs/app/context.md +++ /dev/null @@ -1,13 +0,0 @@ -# Application Context - -The `@welshman/app` package uses a global context system to configure a few core behaviors. - -## Dufflepud - -[Dufflepud](https://github.com/coracle-social/dufflepud) is a utility server that can retrieve NIP 05 profiles, zappers, relay metadata, link previews, etc. It's not necessary for using welshman, but can improve things by bypassing CORS. - -```typescript -import {appContext} from '@welshman/app' - -appContext.dufflepudUrl = 'https://my-dufflepud-instance.com' -``` diff --git a/docs/app/data.md b/docs/app/data.md new file mode 100644 index 0000000..c655807 --- /dev/null +++ b/docs/app/data.md @@ -0,0 +1,184 @@ +# Data Plugins + +These plugins expose reactive collections of nostr data. They all follow the [plugin patterns](./plugins): read synchronously with `get(key)`, reactively with `one(key)` (which lazily loads), and use the convenience accessors that return a [`Projection`](./plugins#projection-t). Resolve each with `app.use(...)`. + +Most event-backed plugins load via the **outbox model**: they first resolve the author's NIP-65 write relays (from [`RelayLists`](#relay-lists)), then query those relays. This is why nearly every data plugin depends on relay lists. + +## Profiles + +Kind-0 profiles keyed by pubkey. + +```typescript +const profiles = app.use(Profiles) + +profiles.one(pubkey) // Readable> — lazily loads +profiles.get(pubkey) // Maybe — sync snapshot, no load +await profiles.load(pubkey) // explicit load (cached) +profiles.display(pubkey) // Projection — display name (falls back to npub) +await profiles.publish(profile) // build & publish a profile event (kind 0) +``` + +`profiles.display(pubkey).$` is the right thing to bind in a component for a user's name. + +## Follows + +Kind-3 follow lists keyed by pubkey. + +```typescript +const follows = app.use(FollowLists) + +follows.one(pubkey) // Readable> +await follows.follow(["p", otherPubkey]) // add a tag and publish to outbox +await follows.unfollow(otherPubkey) // remove and publish +``` + +## Mutes + +Kind-10000 mute lists keyed by pubkey. Private entries are NIP-44 encrypted, so decoding is asynchronous. + +```typescript +const mutes = app.use(MuteLists) + +mutes.one(pubkey) // Readable> +await mutes.mutePublicly(["p", pubkey]) // public mute +await mutes.mutePrivately(["p", pubkey]) // encrypted mute +await mutes.unmute(pubkey) +await mutes.setMutes({publicTags, privateTags}) +``` + +## Pins + +Kind-10001 pin lists keyed by pubkey. + +```typescript +const pins = app.use(PinLists) + +pins.one(pubkey) +await pins.pin(["e", eventId]) +await pins.unpin(eventId) +``` + +## Relay lists + +The NIP-65 relay list (kind 10002) is the routing substrate the whole outbox model depends on. + +```typescript +const relayLists = app.use(RelayLists) + +relayLists.urls(pubkey) // Projection — all relays +relayLists.readUrls(pubkey) // Projection — read relays +relayLists.writeUrls(pubkey) // Projection — write relays + +// Mutations for the current user +await relayLists.addRelay(url, RelayMode.Write) +await relayLists.removeRelay(url, RelayMode.Read) // also notifies the removed relay +await relayLists.setReadRelays(urls) +await relayLists.setWriteRelays(urls) +await relayLists.setRelays(tags) +``` + +### Specialized relay lists + +Each of these is a separate kind with the same shape (`urls(pubkey)`, `addRelay`, `removeRelay`, `setRelays`): + +| Plugin | Kind | Purpose | +|---|---|---| +| `BlockedRelayLists` | 10006 | Relays the user refuses to connect to (also gates [auth](./apppolicies) and [relay quality](./routing#relay-quality)) | +| `MessagingRelayLists` | 10050 | NIP-17 DM inbox relays (used by [gift-wrapped publishing](./publishing#gift-wrapped-messages)) | +| `SearchRelayLists` | 10007 | NIP-50 search relays | + +```typescript +app.use(BlockedRelayLists).urls(pubkey) // Projection +app.use(MessagingRelayLists).urls(pubkey) +app.use(SearchRelayLists).urls(pubkey) +``` + +## Relays (NIP-11) + +Relay metadata fetched over **HTTP**, keyed by relay URL. + +```typescript +const relays = app.use(Relays) + +relays.one(url) // Readable> — lazily fetches NIP-11 +relays.display(url) // Projection +await relays.hasNip(url, 50) // boolean — does the relay support a NIP? +await relays.hasNegentropy(url) // boolean — NIP-77 / negentropy support +``` + +## Relay management (NIP-86) + +```typescript +await app.use(RelayManagement).post(url, managementRequest) +``` + +Builds a NIP-98 HTTP-auth event signed by the current user and sends a NIP-86 management request to the relay. + +## Handles (NIP-05) + +NIP-05 identifiers verified over HTTP, keyed by `name@domain`. Lookups are batched (and use `dufflepudUrl` if configured). + +```typescript +const handles = app.use(Handles) + +handles.forPubkey(pubkey) // Projection> — resolves via the profile's nip05 +handles.display(nip05) // Projection +await handles.loadForPubkey(pubkey) +``` + +## Zappers (Lightning) + +LNURL zapper info keyed by lnurl, fetched over HTTP. + +```typescript +const zappers = app.use(Zappers) + +zappers.forPubkey(pubkey) // Projection> +await zappers.validateZapReceipt(zapReceipt, parentEvent) // Promise> +zappers.validZapReceipts(zapReceipts, parentEvent) // Projection +``` + +## Blossom servers + +Blossom media-server lists (kind 10063) keyed by pubkey. + +```typescript +const list = await app.use(BlossomServerLists).load(pubkey) +app.use(BlossomServerLists).one(pubkey) // Readable> +``` + +## Topics + +Hashtags with usage counts, derived from the repository's tag index. + +```typescript +const topics = app.use(Topics) + +topics.all // Readable ({name, count}) +topics.byName // Readable> +``` + +## Rooms (NIP-29) + +Relay-based group management. Each method builds the relevant room event and publishes it to a single relay as the current user. + +```typescript +const rooms = app.use(Rooms) + +rooms.create(relayUrl, roomMeta) +rooms.edit(relayUrl, roomMeta) +rooms.delete(relayUrl, roomMeta) +rooms.join(relayUrl, roomMeta) +rooms.leave(relayUrl, roomMeta) +rooms.addMember(relayUrl, roomMeta, pubkey) +rooms.removeMember(relayUrl, roomMeta, pubkey) +``` + +## Plaintext + +A cache of decrypted content, keyed by event id. Only decrypts events authored by the current user (e.g. your own private list entries or DMs). + +```typescript +const text = await app.use(Plaintext).ensure(event) // decrypts & caches +const cached = app.use(Plaintext).get(event.id) // sync read of the cache +``` diff --git a/docs/app/feeds-and-search.md b/docs/app/feeds-and-search.md new file mode 100644 index 0000000..05c3612 --- /dev/null +++ b/docs/app/feeds-and-search.md @@ -0,0 +1,77 @@ +# Feeds & Search + +## Feeds + +`app.use(Feeds)` builds `@welshman/feeds` `FeedController`s wired to this app — its router, web-of-trust graph, signer, and net context are all injected for you, so the controller can resolve scopes (`Self`, `Follows`, `Network`, `Followers`) and WoT ranges to real pubkeys and fetch through the app's repository and pool. + +```typescript +import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds" + +const controller = app.use(Feeds).makeFeedController({ + feed: makeIntersectionFeed( + makeScopeFeed(Scope.Follows), + makeKindFeed(1), + ), + onEvent: event => { + // render the event + }, +}) + +await controller.load(50) // load a page of 50 +``` + +### `MakeFeedControllerOptions` + +```typescript +type MakeFeedControllerOptions = Partial> & {feed: Feed} +``` + +You provide the `feed` (and typically `onEvent`); the app injects `router`, `signer`, `context`, and the scope/WoT-range resolvers. The scope resolvers map `@welshman/feeds` `Scope` values to pubkeys via [`Wot`](./wot): + +- `Scope.Self` → the current user +- `Scope.Follows` → `Wot.follows(pubkey)` +- `Scope.Network` → `Wot.network(pubkey)` +- `Scope.Followers` → `Wot.followers(pubkey)` + +WoT-range feeds resolve to the pubkeys whose trust score falls within a fraction of the maximum score in the graph. + +## Search + +`app.use(Searches)` provides fuzzy ([Fuse.js](https://fusejs.io)) search over profiles, topics, and relays. Profile search additionally triggers a NIP-50 network search and ranks results by web of trust. + +```typescript +import {get} from "svelte/store" + +const searches = app.use(Searches) + +// Each of these is a Readable> that stays up to date +const profileSearch = get(searches.profileSearch) +const topicSearch = get(searches.topicSearch) +const relaySearch = get(searches.relaySearch) + +// A Search exposes both option objects and their values +profileSearch.searchValues("alice") // string[] — pubkeys; also fires a NIP-50 network search +profileSearch.searchOptions("alice") // PublishedProfile[] +profileSearch.getOption(pubkey) // PublishedProfile | undefined +``` + +Profile results are ranked by blending the Fuse score with the WoT score, so well-trusted matches surface first. An empty search term returns all options. + +### Building your own search + +The generic `createSearch` helper underlies the built-in searches and is exported for custom indexes: + +```typescript +import {createSearch} from "@welshman/app" + +const search = createSearch(items, { + getValue: item => item.id, // map an item to its identifier + fuseOptions: {keys: ["name", "about"], threshold: 0.3}, + onSearch: term => {/* e.g. trigger a network fetch */}, + sortFn: results => results, // optional custom result ordering +}) + +search.searchOptions("query") // T[] +search.searchValues("query") // V[] +search.getOption(value) // T | undefined +``` diff --git a/docs/app/index.md b/docs/app/index.md index a5878f8..d4b17b8 100644 --- a/docs/app/index.md +++ b/docs/app/index.md @@ -2,82 +2,93 @@ [![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app) -A comprehensive framework for building nostr clients, powering production applications like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). It provides a complete toolkit for managing events, subscriptions, user data, and relay connections. +An instance-based, composable client for building nostr applications. It powers production clients like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social), and ties together the rest of the welshman packages (`util`, `net`, `store`, `router`, `signer`, `feeds`) into a single, cohesive app layer. -## What's Included +## The core idea: an app is an `App` instance -- **Repository** - Event storage and query capabilities -- **Router** - Intelligent relay selection for optimal network access -- **Feed Controller** - Manages feed loading -- **Session Management** - User identity and key management -- **Event Actions** - High-level operations like reacting, replying, etc. -- **Profile Management** - User profile handling and metadata -- **Relay Directory** - Discovery and management of relays -- **Web of Trust** - Utilities for building webs of trust - -## Quick Example +Everything in `@welshman/app` hangs off a single `App` instance. An `App` owns the per-identity primitives — an event `Repository`, a connection `Pool`, a `Tracker`, and a `WrapManager` — plus a `config` and (optionally) a signed-in `User`. Because all state lives on the instance, two apps never share data: you can run multiple identities side-by-side, and tearing one down with `cleanup()` releases everything it allocated. ```typescript -import {getNip07} from '@welshman/signer' -import {load, request, RequestEvent, defaultSocketPolicies, makeSocketPolicyAuth, Socket} from '@welshman/net' -import {StampedEvent, TrustedEvent, makeEvent, NOTE} from '@welshman/util' -import {pubkey, signer, publishThunk} from '@welshman/app' +import {createApp} from "@welshman/app" -// Log in via NIP 07 -addSession({method: 'nip07', pubkey: await getNip07().getPubkey()}) +// A batteries-included app (event ingestion, relay stats, gift-wrap +// unwrapping, and NIP-42 auth are all wired up by default policies) +const app = createApp() +``` -// Enable automatic authentication to relays -defaultSocketPolicies.push( - makeSocketPolicyAuth({ - sign: (event: StampedEvent) => signer.get()?.sign(event), - shouldAuth: (socket: Socket) => true, - }), -) +Features are exposed as **plugins** — lazily-constructed singletons resolved through `app.use(...)`: -// This will fetch the user's profile automatically, and return a store that updates -// automatically. Several different stores exist that are ready to go, including handles, -// zappers, relayLists, relays, follows, mutes. -const profile = deriveProfile(pubkey.get()) +```typescript +import {createApp, Profiles, RelayLists, Thunks} from "@welshman/app" -// Publish is done using thunks, which optimistically publish to the local database, deferring -// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event -// Events are automatically signed using the current session -const thunk = publishThunk({ - relays: Router.get().FromUser().getUrls(), +const app = createApp() + +// Each plugin is constructed once per app and memoized +const profiles = app.use(Profiles) +const relayLists = app.use(RelayLists) +``` + +This replaces the previous global-singleton design (`pubkey`, `deriveProfile`, `publishThunk`, `Router.get()`). There are no module-level globals anymore — you create an app and reach everything through it. + +## Architecture at a glance + +| Layer | What it is | Where | +|---|---|---| +| **`App`** | The app instance; owns repository/pool/tracker/wrapManager and the `use()` registry | [App](./app) | +| **`User` & sessions** | The signed-in identity and serializable login descriptors | [User & Sessions](./user) | +| **Policies** | Side effects installed at construction (ingest, auth, stats, wraps) | [App](./apppolicies) | +| **Plugins** | Lazily-resolved feature modules built on a small set of base classes | [Plugin architecture](./plugins) | +| **Data plugins** | Reactive collections of profiles, lists, relays, handles, zappers… | [Data](./data) | +| **Publishing** | Optimistic publishing via thunks | [Publishing](./publishing) | +| **Requests** | Loading & negentropy sync | [Requests](./requests) | +| **Routing** | Outbox-model relay selection and tag builders | [Routing](./routing) | +| **Web of Trust** | Follow/mute graph scoring | [Web of Trust](./wot) | +| **Feeds & Search** | Feed controllers and fuzzy search | [Feeds & Search](./feeds-and-search) | + +## Quick example + +```typescript +import {createApp, User, toSession, nip07, Profiles, Thunks, Router} from "@welshman/app" +import {getNip07} from "@welshman/signer" +import {makeEvent, NOTE} from "@welshman/util" +import {addMinimalFallbacks} from "@welshman/router" + +// 1. Log in. A session is a serializable {method, data} descriptor; User +// turns it back into a live, signing identity. +const pubkey = await getNip07().getPubkey() +const session = toSession(nip07, {}) +const user = await User.fromSession(session) + +// 2. Create the app around that user. +const app = createApp({user}) + +// 3. Read data reactively. Stores lazily fetch over the network using the +// outbox model and update as events arrive. +const profile = app.use(Profiles).one(pubkey) // Readable> +profile.subscribe($profile => console.log($profile?.name)) + +// 4. Publish optimistically. The event is written to the local repository +// immediately, signed lazily, and progress is reported per-relay. +const thunk = app.use(Thunks).publishToOutbox({ event: makeEvent(NOTE, {content: "hi"}), - delay: 3000, + delay: 3000, // soft-undo window }) -// Thunks can be aborted until after `delay`, allowing for soft-undo -thunk.controller.abort() +// Abort before `delay` elapses to undo +// thunk.abort() +await thunk.waitForCompletion() -// Some commands are included -const thunk = follow(['p', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322']) - -// Load events as a promise -const events = await load({ - relays: Router.get().ForUser().getUrls(), - filters: [{kinds: [NOTE], -}]) - -// Or use `request` for more fine-grained subscription control -const abortController = new AbortController() - -request({ - signal: abortController.signal, - relays: Router.get().ForUser().getUrls(), - filters: [{kinds: [NOTE], - onEvent: (event: TrustedEvent) => { - console.log(event) - }, -}]) - -// Close the request -abortController.abort() +// 5. Tear it all down +app.cleanup() ``` ## Installation ```bash npm install @welshman/app +# or +pnpm add @welshman/app +yarn add @welshman/app ``` + +`@welshman/app` has peer dependencies on `svelte` (4 or 5) and the other welshman workspace packages (`@welshman/feeds`, `@welshman/lib`, `@welshman/net`, `@welshman/router`, `@welshman/signer`, `@welshman/store`, `@welshman/util`), plus `@pomade/core` for the optional Pomade signer. diff --git a/docs/app/making-requests.md b/docs/app/making-requests.md deleted file mode 100644 index 647d9ce..0000000 --- a/docs/app/making-requests.md +++ /dev/null @@ -1,147 +0,0 @@ -# Making Requests - -Welshman extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors. - -## Key Concepts - -- **Local Repository**: Events are automatically cached and tracked -- **Cache Intelligence**: Smart decisions about when to use cached data -- **Relay Integration**: Works with the router for optimal relay selection -- **Configurable Behavior**: Control caching and timeouts - -## Request and Load - -The base functionality for subscription management is implemented in `@welshman/net`. Please refer to [the documentation](/net) for that module for details. - -## Indexed Collections and Loaders - -Create indexed stores with automatic loading using repository derivations and loader utilities: - -```typescript -import {deriveItemsByKey, deriveItems, makeDeriveItem, makeLoadItem, getter} from "@welshman/store" - -// Create indexed map from repository -const itemsByKey = deriveItemsByKey({ - repository, - filters: [{kinds: [SOME_KIND]}], - eventToItem: event => transformEvent(event), - getKey: item => item.id -}) - -// Create array view -const items = deriveItems(itemsByKey) - -// Create getter for accessing map -const getItemsByKey = getter(itemsByKey) - -// Create loader -const loadItem = makeLoadItem(fetchItem, key => getItemsByKey().get(key)) - -// Create deriver with automatic loading -const deriveItem = makeDeriveItem(itemsByKey, loadItem) -``` - -### Deriving Events - -Query events from the repository using `deriveEventsById` and `deriveEvents`: - -```typescript -import {deriveEventsById, deriveEvents} from "@welshman/store" - -const noteEventsById = deriveEventsById({repository, filters: [{kinds: [NOTE]}]}) -export const notes = deriveEvents(noteEventsById) -``` - -### Available Collections - -Several common collections are built-in and ready for use: - -```typescript -// Profiles -profiles → profilesByPubkey → deriveProfile → loadProfile - -// Lists -followLists → followListsByPubkey → deriveFollowList → loadFollowList -muteLists → muteListsByPubkey → deriveMuteList → loadMuteList -pinLists → pinListsByPubkey → derivePinList → loadPinList - -// Relays -relays → relaysByUrl → deriveRelay → loadRelay -relayLists → relayListsByPubkey → deriveRelayList → loadRelayList -messagingRelayLists → messagingRelayListsByPubkey → deriveMessagingRelayList → loadMessagingRelayList - -// Identity -handles → handlesByNip05 → deriveHandle → loadHandle -zappers → zappersByLnurl → deriveZapper → loadZapper -``` - -### Example - Loading and Displaying Profiles - -```typescript -import {get} from 'svelte/store' -import {displayProfile} from '@welshman/util' -import {deriveProfile, deriveProfileDisplay} from '@welshman/app' - -// Subscribe to profile changes - this will automatically load the profile in the background -const profile = deriveProfile(pubkey) - -// Display with fallback -const name = displayProfile(get(profile), 'unknown') - -// Better: use built-in deriveProfileDisplay utility -const name = deriveProfileDisplay(pubkey) -``` - -### User-Specific Collections - -Several modules provide user-specific derived stores that automatically load data for the currently signed-in user: - -```typescript -import { userProfile, userFollowList, userMuteList, userPinList } from '@welshman/app' - -userProfile.subscribe(profile => { - // Current user's profile data -}) - -userFollowList.subscribe(follows => { - // Current user's follow list -}) -``` - -### Repository Integration - -Events from subscriptions are automatically tracked to their source relay and saved to the repository, unless they are DVM-kind or ephemeral events (which are discarded). WRAP (kind 1059) events are handled separately and only processed when `shouldUnwrap` is set to `true`. - -The repository serves as an intelligent cache layer, making subsequent queries for the same data faster. - -## Feeds - -A high-level feed loader utility is also provided, which combines application state with utilities from `@welshman/net` and `@welshman/feeds`. - -```typescript -import {NOTE} from '@welshman/util' -import {makeKindFeed} from '@welshman/feeds' -import {createFeedController} from '@welshman/app' - -const abortController = new AbortController() - -let done = false - -const ctrl = createFeedController({ - feed: makeKindFeed(NOTE), - useWindowing: true, - signal: abortController.signal, - onEvent: e => { - console.log(e) - }, - onExhausted: () => { - done = true - }, -}) - -// Load some notes -ctrl.load(100) - -// Cancel any pending requests -abortController.abort() -``` diff --git a/docs/app/plugins.md b/docs/app/plugins.md new file mode 100644 index 0000000..2c5e878 --- /dev/null +++ b/docs/app/plugins.md @@ -0,0 +1,157 @@ +# Plugin Architecture + +Every feature in `@welshman/app` is a **plugin** — a class constructed with a single `IApp` argument and resolved lazily via `app.use(...)`. All the data-bearing plugins are built on a small set of base classes defined in `plugins/base.ts`. Understanding these three bases and the `Projection` type is enough to read (and extend) the entire library. + +```typescript +const profiles = app.use(Profiles) // new Profiles(app), memoized per app +``` + +## `Projection` + +Almost every accessor in the library returns a `Projection` — a value you can read either synchronously or reactively. + +```typescript +type Projection = { + get: () => T // synchronous "hot" snapshot + $: Readable // a Svelte readable for subscriptions / $-syntax +} +``` + +```typescript +const display = app.use(Profiles).display(pubkey) + +display.get() // string, right now +display.$ // Readable, for `$display` in a component +``` + +Helpers: + +```typescript +// Wrap a Readable into a Projection (default getter is hot-path aware) +projection($: Readable, get?): Projection + +// Derive one Projection from another, preserving both access modes +projectFrom(src: Projection, read: ($: S) => U): Projection +``` + +The default `get` is `getter($)` from `@welshman/store`, which automatically switches between `svelte.get` and a live subscription based on how often it is called — so `.get()` is safe in hot code paths. + +## The three base classes + +| Base class | Source of truth | Loads from network? | Used for | +|---|---|---|---| +| `MapPlugin` | Its own `Map` | No | Local, non-event data (e.g. relay stats) | +| `LoadableMapPlugin` | Its own `Map` | Yes (HTTP) | Data fetched over HTTP (relay NIP-11 info, NIP-05 handles, zappers) | +| `DerivedPlugin` | The `repository` | Yes (events) | Anything derived from nostr events (profiles, lists, …) | + +`DerivedPlugin` is the dominant pattern: it is a live view over the app's event repository, so cached events appear immediately and new ones stream in automatically. + +### `MapPlugin` + +A reactive, keyed in-memory collection that owns its own `Map`. + +```typescript +class MapPlugin { + index: Projection> // the whole Map + all: Projection // values + one: (key?: string, ...args: any[]) => Readable> + + get(key: string): Maybe // sync read + project(key: string, read: (item: Maybe) => U): Projection + set(key: string, value: T): void + delete(key: string): void + clear(): void + onItem(subscriber: (key: string, value: Maybe) => void): Unsubscriber +} +``` + +`set`/`delete`/`clear` fire `onItem` subscribers — handy for persisting the collection to storage. + +### `LoadableMapPlugin` + +A `MapPlugin` that lazily fetches items. Subclasses implement `fetch`; the base adds caching and backoff. + +```typescript +abstract class LoadableMapPlugin extends MapPlugin { + abstract fetch(key: string, ...args: any[]): Promise + + load(key: string, ...args: any[]): Promise> // cached + deduped + backoff + forceLoad(key: string, ...args: any[]): Promise> // bypass the cache +} +``` + +Subscribing to `one(key)` triggers a lazy `load`. Caching, in-flight de-duplication, and exponential backoff come from `makeLoadItem` in `@welshman/store` (default staleness window: one hour). + +### `DerivedPlugin` + +A keyed collection derived from repository events. There is no duplicated map — the repository is the single source of truth. + +```typescript +type DerivedPluginOptions = { + filters: Filter[] + eventToItem: (event: TrustedEvent) => MaybeAsync> + getKey: (item: T) => string + loadOptions?: MakeLoadItemOptions +} + +abstract class DerivedPlugin { + index: Projection> + all: Projection + one: (key?: string, ...args: any[]) => Readable> + + load(key: string, ...args: any[]): Promise> + forceLoad(key: string, ...args: any[]): Promise> + get(key: string): Maybe + project(key: string, read: (item: Maybe) => U): Projection + + abstract fetch(key: string, ...args: any[]): Promise +} +``` + +Internally it builds `index` from `app.use(Stores).itemsByKey({filters, eventToItem, getKey})`, a live readable derived over the repository. `eventToItem` may be async — useful when a list has encrypted entries that must be decrypted first. + +## Lifecycle of a `DerivedPlugin` read + +1. **Read (cached):** `get(key)` (sync) or `one(key)` (reactive) returns whatever already matches in the repository — instantly. +2. **Lazy load:** subscribing to `one(key)` (or calling `load(key)`) triggers `fetch(key)`. Caching skips recently-loaded keys; in-flight calls for the same key collapse; failures back off exponentially. +3. **Decode:** inbound events flow through `eventToItem`. Async decoders resolve and update the index when ready. +4. **Derive:** convenience accessors (`display(...)`, `urls(...)`, …) are `project(key, read)` calls returning a `Projection`. + +`forceLoad` bypasses the cache and resolves to the freshly-read item. + +## The `Stores` plugin + +`app.use(Stores)` is the repository/tracker-bound factory that `DerivedPlugin` builds on. It mostly forwards to `@welshman/store`, injecting the app's `repository` and `tracker`: + +- `itemsByKey(opts)` — the live keyed collection used by `DerivedPlugin` +- `events(opts)` / `eventsById(opts)` / `makeEvent(opts)` — derived event stores +- `eventsByIdByUrl(opts)` / `eventsByIdForUrl(opts)` — relay-scoped views (inject the tracker) +- `isDeleted(event)` — reactive deletion status + +You rarely call `Stores` directly — the higher-level data plugins are usually what you want — but it is the seam to use when you need a custom repository-derived store wired to the app. + +## Writing your own plugin + +A plugin is any class with the shape `new (app: IApp) => T`. Extend one of the base classes for a data collection, or write a plain class for behavior: + +```typescript +import {DerivedPlugin, Network, type IApp} from "@welshman/app" +import {SOME_KIND, readSomething} from "@welshman/util" + +export class Somethings extends DerivedPlugin> { + constructor(app: IApp) { + super(app, { + filters: [{kinds: [SOME_KIND]}], + eventToItem: event => readSomething(event), + getKey: item => item.event.pubkey, + }) + } + + fetch = (pubkey: string, relayHints: string[] = []) => + this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SOME_KIND]}, relayHints) +} + +// usage +const things = app.use(Somethings) +const thing$ = things.one(pubkey) // lazily loads via the outbox model +``` diff --git a/docs/app/publishing-events.md b/docs/app/publishing-events.md deleted file mode 100644 index 7304459..0000000 --- a/docs/app/publishing-events.md +++ /dev/null @@ -1,76 +0,0 @@ -# Thunks - -Thunks provide optimistic updates for event publishing. They immediately update the local repository while handling the actual signing and publishing asynchronously, making the UI feel more responsive. - -## Overview - -A thunk: - -- Updates local state immediately -- Handles event signing in the background using the current session -- Tracks publish status per relay -- Supports soft-undo via abort -- Can be delayed/cancelled -- Tracks successful publishes - -## Basic Usage - -```typescript -import {publishThunk} from '@welshman/app' -import {createEvent, NOTE} from '@welshman/util' - -const publish = async (content: string) => { - // Get optimal relays for publishing - const relays = ctx.app.router - .FromUser() - .getUrls() - - // Create and publish thunk - const thunk = await publishThunk({ - event: createEvent(NOTE, {content}), - relays, - delay: 3000, // 3s window for abort - }) - - // Track publish results - thunk.subscribe($thunk => { - for (const [url, result] of Object.entries($thunk.results)) { - console.log(`${url}: ${result.status} - ${result.detail}`) - } - }) - - // Can abort within delay window - setTimeout(() => { - if (userWantsToCancel) { - thunk.controller.abort() - } - }, 1000) - - // Wait for completion - await thunk.complete -} -``` - -## Built in commands - -Several thunk factories are provided for common or more complicated scenarios like updating lists: - -- `removeRelay(url: string, mode: RelayMode)` -- `addRelay(url: string, mode: RelayMode)` -- `removeMessagingRelay(url: string)` -- `addMessagingRelay(url: string)` -- `setProfile(profile: Profile)` -- `unfollow(value: string)` -- `follow(tag: string[])` -- `unmute(value: string)` -- `mutePublicly(tag: string[])` -- `mutePrivately(tag: string[])` -- `unpin(value: string)` -- `pin(tag: string[])` -- `sendWrapped({event, recipients, ...options}: SendWrappedOptions)` -- `manageRelay(url: string, request: ManagementRequest)` -- `createRoom(url: string, room: RoomMeta)` -- `deleteRoom(url: string, room: RoomMeta)` -- `editRoom(url: string, room: RoomMeta)` -- `joinRoom(url: string, room: RoomMeta)` -- `leaveRoom(url: string, room: RoomMeta)` diff --git a/docs/app/publishing.md b/docs/app/publishing.md new file mode 100644 index 0000000..0ba861b --- /dev/null +++ b/docs/app/publishing.md @@ -0,0 +1,117 @@ +# Publishing Events + +Publishing in `@welshman/app` is **optimistic** and built around *thunks*. A thunk writes the event to the local repository immediately (so the UI updates instantly), signs lazily, optionally gift-wraps (NIP-59) and computes proof-of-work (NIP-13), and reports acceptance/rejection per relay. The signing/publishing can be delayed, giving you a soft-undo window. + +Publishing is managed by the `Thunks` plugin: `app.use(Thunks)`. + +## Publishing to specific relays + +```typescript +import {makeEvent, NOTE} from "@welshman/util" + +const thunk = app.use(Thunks).publish({ + event: makeEvent(NOTE, {content: "hi"}), + relays: ["wss://relay.example"], +}) +``` + +## Publishing to the outbox + +`publishToOutbox` resolves the current user's write relays (via the [Router](./routing)) for you — the usual way to publish your own notes. + +```typescript +const thunk = app.use(Thunks).publishToOutbox({ + event: makeEvent(NOTE, {content: "hi"}), + delay: 3000, // wait 3s before signing/sending — abortable until then +}) +``` + +## `ThunkOptions` + +```typescript +type ThunkOptions = Override +``` + +`publish`/`publishToOutbox` accept these options minus `app` (and minus `relays` for `publishToOutbox`). + +## Working with a thunk + +A thunk is a Svelte store; subscribe to watch per-relay progress. + +```typescript +const thunk = app.use(Thunks).publish({event, relays}) + +thunk.subscribe(t => console.log(t.results)) // PublishResultsByRelay + +// Soft-undo: only effective before `delay` elapses +thunk.abort() + +// Inspect status +thunk.getCompleteUrls() +thunk.getIncompleteUrls() +thunk.getFailedUrls() +thunk.isComplete() +thunk.getError() // string | undefined + +// Await outcomes +await thunk.waitForCompletion() // resolves when no relay is still pending +await thunk.waitForError() // resolves with the first error string +``` + +## Optimistic-publish history + +The `Thunks` manager keeps a log of all thunks and supports retrying: + +```typescript +const thunks = app.use(Thunks) + +thunks.history // writable — the optimistic publish log +thunks.retry(thunk) // re-publish a (possibly merged) thunk +``` + +Each thunk is queued (batched) and its event is written to the repository and tracker the moment it is enqueued, so derived stores reflect it before any relay has responded. If a thunk is aborted before sending, its event and wrap are removed from the repository and its history entry is dropped. + +## Gift-wrapped messages + +There are two ways to publish encrypted, NIP-59 gift-wrapped events. + +### A single thunk with a `recipient` + +Set `recipient` on a normal thunk. The thunk wraps the rumor with an ephemeral key, registers it with the app's `WrapManager`, and publishes the wrap: + +```typescript +app.use(Thunks).publish({ + event: rumorTemplate, + relays: theirMessagingRelays, + recipient: theirPubkey, +}) +``` + +### Many recipients via `Wraps` + +The `Wraps` plugin publishes one wrap per recipient, resolving each recipient's NIP-17 messaging relays automatically: + +```typescript +const merged = await app.use(Wraps).publish({ + event: rumorTemplate, + recipients: [pubkeyA, pubkeyB], +}) + +await merged.waitForCompletion() +``` + +`Wraps.publish` returns a `MergedThunk` aggregating the per-recipient thunks. Incoming wraps addressed to the current user are unwrapped automatically by the [`appPolicyWraps`](./apppolicies) default policy; wraps that fail to unwrap (or are duplicates) are skipped. + +## Proof of work + +Set `pow` to a target difficulty (number of leading zero bits). The thunk mines the PoW before signing; for wrapped events the wrap itself is mined. + +```typescript +app.use(Thunks).publish({event, relays, pow: 20}) +``` diff --git a/docs/app/relay-selection.md b/docs/app/relay-selection.md deleted file mode 100644 index d428483..0000000 --- a/docs/app/relay-selection.md +++ /dev/null @@ -1,56 +0,0 @@ -# Router - -The Welshman router can be used to enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions. - -## Overview - -The router provides scenarios for common **Nostr** operations: - -- Reading user profiles -- Publishing events -- Following threads -- Handling DMs -- Searching content - -Each scenario considers: - -- User's relay preferences (NIP-65) -- Event hints in tags -- Relay quality scores -- Fallback policies -- Connection status - -## Basic Usage - -```typescript -import {routerContext, addMaximalFallbacks, Router} from '@welshman/app' - -// Set up global router options -routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"] - -// Router can be used directly with options, or via a singleton with global options -const router = Router.get() - -// Get relays for reading a profile -const readRelays = router.ForPubkey(pubkey).getUrls() - -// Get relays for broadcasting events by the current user -const writeRelays = router.FromUser().getUrls() - -// Get relays for a quote -const quoteRelays = Router.get() - .Quote(parentEvent, idOrAddress, relayHints) - .policy(addMaximalFallbacks) - .getUrls() - -``` - -## Router Features - -- Smart relay selection based on relay monitoring -- Quality scoring of relays -- Fallback strategies -- Handling of special relay types (.onion, local) -- NIP-65 support - -The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic. diff --git a/docs/app/requests.md b/docs/app/requests.md new file mode 100644 index 0000000..804be15 --- /dev/null +++ b/docs/app/requests.md @@ -0,0 +1,69 @@ +# Making Requests + +The `Network` plugin (`app.use(Network)`) wraps the `@welshman/net` request/publish/negentropy functions, injecting the app's net context (its pool and repository) so you don't have to pass it every time. The `Sync` plugin (`app.use(Sync)`) builds on top for negentropy-aware reconciliation. + +## Loading and requesting + +```typescript +const net = app.use(Network) + +// One-shot load — resolves with matching events +const events = await net.load({ + filters: [{kinds: [1], authors: [pubkey]}], + relays: ["wss://relay.example"], +}) + +// Open a subscription +await net.request({ + filters: [{kinds: [1]}], + relays: ["wss://relay.example"], + autoClose: true, +}) +``` + +`net.load` is a shared, batched loader (created with a 50ms delay / 3s timeout). Use `net.makeLoader(options)` if you need a loader with different batching characteristics. + +```typescript +publish(options) // publish an event (prefer the Thunks plugin for app publishing) +makeLoader(options) // build a custom batched Loader +``` + +## The outbox model: `loadUsingOutbox` + +`loadUsingOutbox` is the workhorse most data plugins use. Given an author's pubkey, it resolves that author's NIP-65 **write** relays, routes them (with minimal fallbacks, capped at 8), queries them a couple at a time, and resolves with the most recent matching event as soon as any relay responds. + +```typescript +const latestProfile = await net.loadUsingOutbox(pubkey, {kinds: [0]}) + +// With relay hints to try first +const note = await net.loadUsingOutbox(pubkey, {kinds: [1], limit: 1}, ["wss://hint.example"]) +``` + +The filter is always constrained to `authors: [pubkey]`. This is the mechanism behind the lazy loading you get from `app.use(Profiles).one(pubkey)`, `FollowLists`, `MuteLists`, and friends. + +## Negentropy sync + +`Sync` reconciles the local repository with relays using NIP-77 (negentropy) where available, and falls back to plain request/publish where it isn't (detected via `app.use(Relays).hasNegentropy(url)`). + +```typescript +type AppSyncOpts = {relays: string[]; filters: Filter[]} + +const sync = app.use(Sync) + +// Pull missing events from relays into the local repository +await sync.pull({relays, filters: [{kinds: [3], authors: [pubkey]}]}) + +// Push local events up to relays +await sync.push({relays, filters: [{authors: [pubkey]}]}) + +// Query the local repository (sorts unless any filter has a limit) +const local = sync.query([{kinds: [1]}]) +``` + +`pull` and `push` operate per relay: if the relay supports negentropy they use efficient set-reconciliation (`net.pull`/`net.push`); otherwise they fall back to a normal request (pull) or publishing each event individually (push). Low-level negentropy primitives are also exposed directly on `Network`: + +```typescript +net.diff(options) // compute a NIP-77 set difference +net.pull(options) // negentropy pull +net.push(options) // negentropy push +``` diff --git a/docs/app/routing.md b/docs/app/routing.md new file mode 100644 index 0000000..560b1b8 --- /dev/null +++ b/docs/app/routing.md @@ -0,0 +1,79 @@ +# Routing & Tags + +## The Router + +`app.use(Router)` is a per-app `Router` (from `@welshman/router`) wired to this app's data. It is the single source for relay selection — there is no global `Router.get()` anymore; one router belongs to each app. + +The app wires it up with: + +- **user pubkey** from `app.user` +- **read/write relays** per pubkey from [`RelayLists`](./data#relay-lists) +- **relay quality** from [`RelayStats`](#relay-quality) +- **default / indexer / search relays** from [`AppConfig`](./appappconfig) + +### Relay-selection scenes + +The router exposes composable "scenes" (inherited from the base router) that resolve to a relay set: + +```typescript +const router = app.use(Router) + +router.FromUser() // the current user's relays +router.FromPubkey(pubkey) // another user's relays +router.FromRelays(urls) // explicit relays +router.Event(event) // relays where an event is likely found +router.EventRoots(event) // relays for an event's thread roots +router.Search() // search relays +``` + +Scenes are chainable and terminate in `getUrls()` / `getUrl()`: + +```typescript +import {addMinimalFallbacks} from "@welshman/router" + +const relays = router.FromUser().policy(addMinimalFallbacks).limit(8).getUrls() +const hint = router.Event(event).getUrl() +``` + +## Relay quality + +`app.use(RelayStats)` collects per-relay connection statistics (open/close/publish/request/event counts, timestamps, recent errors) and exposes a quality score the router uses to rank relays. + +```typescript +const stats = app.use(RelayStats) + +stats.one(url) // Readable> +stats.getQuality(url) // number in [0, 1] — 0 for blocked/error-prone relays +``` + +Stats are populated automatically by the [`appPolicyRelayStats`](./apppolicies) default policy. `getQuality` returns `0` for non-relay URLs, relays in the user's [blocked list](./data#specialized-relay-lists), or error-prone relays, and higher scores for relays that are connected or have been seen before. + +## Tag utilities + +`app.use(Tags)` builds nostr tags using the router for relay hints, `Profiles` for display names, and the current user to avoid self-tagging. + +```typescript +const tags = app.use(Tags) + +tags.tagPubkey(pubkey) // ["p", pubkey, hint, name] +tags.tagEvent(event, url?, mark?) // [["e", id, hint, mark, pubkey], ("a", ...)? ] +tags.tagEventPubkeys(event) // de-duped p-tags (author + mentions, minus self) +tags.tagZapSplit(pubkey, split?) // ["zap", pubkey, hint, split] + +tags.tagEventForReply(event, relay?) // reply tag set (root/reply e/a + p tags) +tags.tagEventForComment(event, relay?) // NIP-22 comment tags (K/E/A/I/P + k/p/e) +tags.tagEventForQuote(event, relay?) // ["q", id, hint, pubkey] +tags.tagEventForReaction(event, relay?) // p, ["k", kind], ["e", id, hint], ("a", ...)? +``` + +A typical reply: + +```typescript +import {makeEvent, NOTE} from "@welshman/util" + +const replyTags = app.use(Tags).tagEventForReply(parentEvent) + +app.use(Thunks).publishToOutbox({ + event: makeEvent(NOTE, {content: "well said", tags: replyTags}), +}) +``` diff --git a/docs/app/session.md b/docs/app/session.md deleted file mode 100644 index c57cfef..0000000 --- a/docs/app/session.md +++ /dev/null @@ -1,210 +0,0 @@ -# Session Management - -The session system provides a unified way to handle different authentication methods: - -- NIP-01 via Secret Key -- NIP-07 via Browser Extension -- NIP-46 via Bunker URL or Nostrconnect -- NIP-55 via Android Signer Application -- Read-only pubkey login - -## Overview - -Sessions are stored in local storage and can be: - -- Persisted across page reloads -- Used with multiple accounts -- Switched dynamically -- Backed by different signing methods - -## NIP 01 Example - -The simplest type of login is NIP 01, although it's generally a bad idea to be handling user keys. NIP 46, 44, or 07 login are preferable. However, NIP 01 can be useful for supporting signup, local profiles, or ephemeral keys. - -```typescript -import {makeSecret} from '@welshman/util' -import {loginWithNip01} from '@welshman/app' - -loginWithNip01(makeSecret()) -``` - -## NIP 07 Example - -A simple way to sign in for desktop browser users is using [NIP 07](https://github.com/nostr-protocol/nips/blob/master/07.md). This method is easy to implement, but should be used sparingly, since not all users will be using a browser with a nostr signing extension installed. - -```typescript -import {Nip07Signer} from '@welshman/signer' -import {loginWithNip07} from '@welshman/app' - -const signer = new Nip07Signer() - -signer.getPubkey().then(pubkey => { - if (pubkey) { - loginWithNip07(pubkey) - } else { - // User extension does not exist or did not respond - } -}) -``` - -## NIP-46 Authentication - -The best default signing scheme is [NIP 46](https://github.com/nostr-protocol/nips/blob/master/46.md), AKA "Nostr Connect". This supports multiple handshakes depending on desired UX, and can support advanced use cases like secure enclaves, self-hosted keys, and FROST multisig. - -The simpler `bunker://` handshake is done by asking the user to provide a bunker URL, either by QR code, or by pasting it manually into your application. - -```typescript -import {makeSecret} from "@welshman/util" -import {Nip46Broker} from "@welshman/signer" -import {loginWithNip46, nip46Perms} from "@welshman/app" -import {isKeyValid} from "src/util/nostr" - -// Make a client secret - this is distinct from the user's private key, and is used -// for communicating securely with the remote signer -const clientSecret = makeSecret() - -// Ask the user to input their bunker URL -const bunkerUrl = prompt("Please enter your bunker url") - -// Pase the bunker url -const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunkerUrl) - -if (!isKeyValid(signerPubkey)) { - alert("Sorry, but that's an invalid public key.") -} else if (relays.length === 0) { - alert("That connection string doesn't have any relays.") -} else { - // Open up a connection with the signer - const broker = Nip46Broker.get({relays, clientSecret, signerPubkey}) - - // Send a connect request with the default permissions - const result = await broker.connect(connectSecret, nip46Perms) - - // Make sure to check the connect secret to prevent hijacking - if (result === connectSecret) { - // Get the user's public key - const pubkey = await broker.getPublicKey() - - if (!pubkey) { - alert("Failed to initialize session") - } else { - loginWithNip46(pubkey, clientSecret, signerPubkey, relays) - } - } -} -``` - -Alternatively, you can provide the user with a `nostrconnect://` URL which they can copy or scan with their signer. This is a better UX for users using a signer on their mobile phone. - -```typescript -import {makeSecret} from "@welshman/util" -import {Nip46Broker} from "@welshman/signer" -import {loginWithNip46, nip46Perms} from "@welshman/app" - -// Create a client secret -const clientSecret = makeSecret() - -// Stop listening if the user cancels login -const abortController = new AbortController() - -// Customize to use relays the signer can send responses to -const relays = ['wss://relay.nsec.app/'] - -// Create a broker -const broker = Nip46Broker.get({clientSecret, relays}) - -// Create a nostrconnect:// url -const nostrconnect = await broker.makeNostrconnectUrl({ - name: "My App", - url: window.origin, - image: window.origin + '/logo.png', - perms: nip46Perms, -}) - -// Share it with the user. Displaying a QR code is particularly helpful -alert("To connect, paste this URL into your signer: " + nostrconnect) - -// Listen for the response -let response -try { - response = await broker.waitForNostrconnect(nostrconnect, abortController.signal) -} catch (errorResponse: any) { - if (errorResponse?.error) { - alert(`Received error from signer: ${errorResponse.error}`) - } else if (errorResponse) { - console.error(errorResponse) - } -} - -// If we got a response, the broker is already connected and we can log in -if (response) { - const pubkey = await broker.getPublicKey() - - if (!pubkey) { - alert("Failed to initialize session") - } else { - loginWithNip46(pubkey, clientSecret, response.event.pubkey, relays) - } -} -``` - -## NIP-55 Authentication - -For the best UX on Android, use [NIP 55](https://github.com/nostr-protocol/nips/blob/master/55.md). Note that this only works for web applications that have been compiled to native Android applications using [CapacitorJS](https://capacitorjs.com/) and [nostr-signer-capacitor-plugin](https://github.com/chebizarro/nostr-signer-capacitor-plugin). - -```typescript -import {getNip55, Nip55Signer, loginWithNip55} from "@welshman/signer" - -// Query for installed apps that implement nip 55 signing -getNip55().then(signerApps => { - // We'll choose the first one and auto-login, but in most cases you'll want to offer a choice - if (signerApps.length > 0) { - const signer = new Nip55Signer(signerApps[0].packageName) - const pubkey = await signer.getPubkey() - - if (pubkey) { - loginWithNip55(pubkey, app.packageName) - } - } -}) -``` - -## Read-only session - -A fun feature of nostr is that you can log in as other people, and see what nostr is like from their perspective (minus encrypted data or course). - -```typescript -import {loginWithPubkey} from "@welshman/app" - -// Log in as hodlbod -loginWithPubkey("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322") -``` - -## Using the current session - -```typescript -import {signer, session} from '@welshman/app' -import {createEvent, NOTE} from '@welshman/util' - -// Print the current session - be aware the private key is stored in memory, be very -// careful about how you handle session objects! -console.log(session.get()) - -// Current session's signer is always ready to use -const event = await signer.get().sign( - createEvent(NOTE, {content: "Hello Nostr!"}) -) - -// hodlbod's pubkey -const otherPubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" - -// Encrypt content for private notes -const ciphertext = await signer.get().nip44.encrypt(otherPubkey, "Secret message") - -// Decrypt automatically detects encryption version -const plaintext = await decrypt(signer, otherPubkey, ciphertext) -``` - -## Multiple sessions - -It's possible to support multiple concurrent sessions by simply calling `addSession` multiple times. This will update `sessions`, and set `pubkey` to the most recently added session. You can then switch between sessions by calling `pubkey.set` with a valid session pubkey, and delete sessions using `dropSession(pubkey)`. diff --git a/docs/app/tags.md b/docs/app/tags.md deleted file mode 100644 index 4db9dfa..0000000 --- a/docs/app/tags.md +++ /dev/null @@ -1,46 +0,0 @@ -# Tag Utilities - -The tag utilities provide helper functions for creating properly formatted Nostr event tags with correct relay hints and metadata. - -These are especially useful when creating events that reference other events or users. - -## Tag Creators - -### Pubkey Tags - -```typescript -import {tagPubkey} from '@welshman/app' - -// Create a p-tag with relay hint and profile name -const tag = tagPubkey(authorPubkey) -// => ["p", pubkey, "wss://relay.example.com", "username"] -``` - - -### Event Reference Tags - -```typescript -import { - tagEvent, // Basic event reference - tagEventForQuote, // For quoting events - tagEventForReply, // For reply threads - tagEventForComment, // For NIP-22 comments - tagEventForReaction // For reactions -} from '@welshman/app' - -// Real world example: Creating a reply -const createReply = async (parent: TrustedEvent, content: string) => { - // Get proper tags for a reply, including: - // - All referenced pubkeys - // - Root/reply markers - // - Inherited mentions - // - Relay hints - const tags = tagEventForReply(parent) - - return publishThunk({ - // Use relay hints from tags - relays: Router.get().PublishEvent(event).getUrls() - event: await signer.get().sign(createEvent(NOTE, {content, tags})), - }) -} -``` diff --git a/docs/app/user.md b/docs/app/user.md index 66a9e04..a1f1539 100644 --- a/docs/app/user.md +++ b/docs/app/user.md @@ -1,100 +1,133 @@ -# User Data Loading +# User & Sessions -The User Data module provides utilities for loading and managing user-specific data like profiles, follows, mutes, pins, and relay selections. It includes both reactive stores and manual loading functions. +An `App` is centered on at most one identity, represented by a `User`. Login state that needs to be persisted is represented separately as a serializable `Session`. The two are connected by session handlers, which know how to turn a serialized session back into a live signer. -## User Data Stores +## `User` -These reactive stores automatically load and cache user data: +A `User` is a single identity: a `pubkey` plus the `signer` that proves ownership of it. ```typescript -// User profile -export const userProfile: Store +class User { + constructor(readonly pubkey: string, readonly signer: ISigner) -// User follows list -export const userFollowList: Store + static fromSigner(signer: ISigner): Promise + static fromSession(session: Session): Promise + static require(app: IApp): User -// User mutes list -export const userMuteList: Store - -// User pins list -export const userPinList: Store - -// User relay selections -export const userRelayList: Store - -// User messaging relay selections -export const userMessagingRelayList: Store - -// User blossom servers -export const userBlossomServerList: Store + sign(event: StampedEvent): Promise + nip44EncryptToSelf(payload: string): Promise +} ``` -## Manual Loading Functions +### Constructing a user -These functions load user data for the currently signed-in user with optional relay hints: +- **`User.fromSigner(signer)`** — wraps `signer` in a [`LoggingSigner`](./applogging) (unless it already is one), derives the pubkey via `signer.getPubkey()`, and returns the `User`. +- **`User.fromSession(session)`** — resolves a signer from a serialized session (via the [handler registry](#session-handlers)) and returns the `User`, or `undefined` if no handler is registered for the session's method. ```typescript -// Load user profile -function loadUserProfile(relays?: string[]): Promise +import {User} from "@welshman/app" +import {getNip07} from "@welshman/signer" -// Load user follows -function loadUserFollowList(relays?: string[]): Promise - -// Load user mutes -function loadUserMuteList(relays?: string[]): Promise - -// Load user pins -function loadUserPinList(relays?: string[]): Promise - -// Load user relay selections -function loadUserRelayList(relays?: string[]): Promise - -// Load user messaging relay selections -function loadUserMessagingRelayList(relays?: string[]): Promise - -// Load user blossom servers -function loadUserBlossomServerList(relays?: string[]): Promise +const user = await User.fromSigner(getNip07()) ``` -Force-reload variants bypass the cache and always fetch fresh data: +### Gating user-only actions + +`User.require(app)` returns `app.user`, throwing `"This action requires a signed-in user"` if there is none. Plugins use this internally before signing or encrypting; you can use it the same way. ```typescript -function forceLoadUserProfile(relays?: string[]): Promise -function forceLoadUserFollowList(relays?: string[]): Promise -function forceLoadUserMuteList(relays?: string[]): Promise -function forceLoadUserPinList(relays?: string[]): Promise -function forceLoadUserRelayList(relays?: string[]): Promise -function forceLoadUserMessagingRelayList(relays?: string[]): Promise -function forceLoadUserBlossomServerList(relays?: string[]): Promise +const user = User.require(app) +const signed = await user.sign(stampedEvent) ``` -## Usage Examples +### Signing & self-encryption + +- **`sign(event)`** delegates to the signer. +- **`nip44EncryptToSelf(payload)`** encrypts a payload to your own pubkey via NIP-44 — used for private list entries (mutes, follows) that only you should read. + +## `Session` + +A `Session` is a serializable login descriptor. It contains only data — never a live signer object — so it can be stored in `localStorage`, IndexedDB, or anywhere else, and rehydrated later. -### Using Reactive Stores ```typescript -import { userProfile, userFollowList } from '@welshman/app' +type Session = {method: M; data: D} +``` -// Subscribe to user profile changes -userProfile.subscribe(profile => { - if (profile) { - console.log('User profile:', profile) - } +### Building sessions + +Build a typed session from a handler with `toSession`: + +```typescript +toSession(handler: SessionHandler, data: D): Session +``` + +```typescript +import {toSession, nip01, nip07, nip46} from "@welshman/app" + +const a = toSession(nip01, {secret: ""}) +const b = toSession(nip07, {}) +const c = toSession(nip46, {clientSecret, signerPubkey, relays}) +``` + +## Session handlers + +A `SessionHandler` maps a session's `data` back to an `ISigner`: + +```typescript +type SessionHandler = { + method: M + getSigner: (data: D) => MaybeAsync +} +``` + +### Built-in handlers + +These are registered automatically when the package loads: + +| Handler | `method` | `data` shape | Signer | +|---|---|---|---| +| `nip01` | `"nip01"` | `{secret: string}` | `Nip01Signer` | +| `nip07` | `"nip07"` | `{}` | `Nip07Signer` (browser extension) | +| `nip46` | `"nip46"` | `{clientSecret, signerPubkey, relays}` | `Nip46Signer` (remote signer / bunker) | +| `nip55` | `"nip55"` | `{pubkey, signer}` | `Nip55Signer` (Android signer app) | +| `pomade` | `"pomade"` | `{clientOptions, email}` | `PomadeSigner` | + +### Registering custom handlers + +Define a handler with `defineSessionHandler` (it infers `M`/`D` so `getSigner` is type-checked against the data shape), then register it: + +```typescript +import {defineSessionHandler, registerSessionHandler, unregisterSessionHandler} from "@welshman/app" + +const myHandler = defineSessionHandler({ + method: "my-method", + getSigner: (data: {token: string}) => new MyCustomSigner(data.token), }) -// Get current follows list -const follows = userFollowList.get() +registerSessionHandler(myHandler) +// later: unregisterSessionHandler(myHandler) ``` -### Manual Loading +### Resolving signers directly + ```typescript -import { loadUserMuteList, forceLoadUserRelayList } from '@welshman/app' - -// Load user mutes from specific relays -await loadUserMuteList(['wss://relay1.com', 'wss://relay2.com']) - -// Force refresh user relay selections -await forceLoadUserRelayList([]) - -// Load from default relays -await loadUserProfile() +getSignerFromSession(session: Session): MaybeAsync | undefined +``` + +Returns the signer for a session, or `undefined` if no handler is registered for its method. `User.fromSession` is a thin wrapper over this. + +## A complete login flow + +```typescript +import {createApp, User, toSession, nip07} from "@welshman/app" +import {getNip07} from "@welshman/signer" + +// On login: build a serializable session and persist it +const session = toSession(nip07, {}) +localStorage.setItem("session", JSON.stringify(session)) + +// On startup: rehydrate the user and create the app +const stored = JSON.parse(localStorage.getItem("session")) +const user = await User.fromSession(stored) // User | undefined +const app = createApp({user}) ``` diff --git a/docs/app/wot.md b/docs/app/wot.md index 06d6269..0de63a6 100644 --- a/docs/app/wot.md +++ b/docs/app/wot.md @@ -1,63 +1,55 @@ -# Web of Trust (WOT) +# Web of Trust -Welshman provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery. +`app.use(Wot)` computes a web-of-trust graph from follow ([`FollowLists`](./data#follows)) and mute ([`MuteLists`](./data#mutes)) lists, rooted at the current user. When there is no signed-in user, the graph is built from the union of all known follow lists. All computations are throttled (1s) to stay cheap under churn. -## Core Concepts +The score for a pubkey is the number of roots that follow it minus the number that mute it. -- **Follow Trust**: Users gain positive reputation when followed by those in your network -- **Mute Distrust**: Users lose reputation when muted by those in your network -- **WOT Graph**: A reactive weighted directed graph representing trust relationships -- **Contextual Scoring**: Reputation scores that adapt based on user's social graph +## Aggregate projections -## API Reference - -### Social Graph Navigation +Each returns a [`Projection`](./plugins#projection-t) (`.get()` / `.$`): ```typescript -// Get users followed by a specific pubkey -getFollows(pubkey: string): string[] +const wot = app.use(Wot) -// Get users who have muted a specific pubkey -getMutes(pubkey: string): string[] - -// Get followers of a specific pubkey -getFollowers(pubkey: string): string[] - -// Get users who have muted a specific pubkey -getMuters(pubkey: string): string[] - -// Get the extended network (follows-of-follows) for a pubkey -getNetwork(pubkey: string): string[] +wot.graph // Projection> — score per pubkey +wot.max // Projection — highest score in the graph +wot.followersByPubkey // Projection>> +wot.mutersByPubkey // Projection>> ``` -### Trust Analysis +## Per-pubkey queries ```typescript -// Get follows of a user who also follow a target -getFollowsWhoFollow(pubkey: string, target: string): string[] +wot.follows(pubkey) // Projection — who pubkey follows +wot.mutes(pubkey) // Projection — who pubkey mutes +wot.followers(pubkey) // Projection — who follows pubkey +wot.muters(pubkey) // Projection — who mutes pubkey +wot.network(pubkey) // Projection — follows-of-follows (minus direct follows) -// Get follows of a user who have muted a target -getFollowsWhoMute(pubkey: string, target: string): string[] - -// Calculate trust score between users -getWotScore(pubkey: string, target: string): number +wot.followsWhoFollow(pubkey, target) // Projection +wot.followsWhoMute(pubkey, target) // Projection +wot.wotScore(pubkey, target) // Projection ``` -### Reactive Stores +`wotScore(pubkey, target)`: + +- With a `pubkey`: `(pubkey's follows who follow target) − (pubkey's follows who mute target)`. +- Without a `pubkey`: `followers(target).length − muters(target).length`. + +## Examples ```typescript -// Map of follower lists by pubkey -followersByPubkey: Readable>> +const wot = app.use(Wot) -// Map of muter lists by pubkey -mutersByPubkey: Readable>> +// Sort a list of pubkeys by trust, descending +const graph = wot.graph.get() +const sorted = [...pubkeys].sort((a, b) => (graph.get(b) ?? 0) - (graph.get(a) ?? 0)) -// The full WOT graph with scores (pubkey → score) -wotGraph: Writable> +// Reactive trust score between me and someone else +const score$ = wot.wotScore(myPubkey, theirPubkey).$ -// The maximum WOT score in the graph -maxWot: Readable - -// Derive the WOT score for a specific user -deriveUserWotScore(targetPubkey: string): Readable +// Discover the extended network for a "follows of follows" feed +const network = wot.network(myPubkey).get() ``` + +The WoT graph also feeds [profile search](./feeds-and-search#search) ranking and the `Scope`/WoT-range pubkey resolution used by [feeds](./feeds-and-search#feeds). diff --git a/packages/app/__tests__/tags.test.ts b/packages/app/__tests__/tags.test.ts deleted file mode 100644 index 406c4a9..0000000 --- a/packages/app/__tests__/tags.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import {COMMENT, getAddress, MUTES, NOTE} from "@welshman/util" -import {beforeEach, describe, expect, it, vi} from "vitest" -import { - tagEvent, - tagEventForComment, - tagEventForQuote, - tagEventForReaction, - tagEventForReply, - tagEventPubkeys, - tagPubkey, - tagZapSplit, -} from "../src/tags" - -describe("tags", () => { - const id = "00".repeat(32) - const id1 = "11".repeat(32) - const id2 = "22".repeat(32) - - const pubkey = "aa".repeat(32) - const pubkey1 = "bb".repeat(32) - const pubkey2 = "cc".repeat(32) - - const mockEvent: any = { - id, - pubkey, - kind: 1, - tags: [], - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe("tagZapSplit", () => { - it("should create zap split tag with default split", () => { - const result = tagZapSplit(pubkey1) - expect(result).toEqual(["zap", pubkey1, expect.any(String), "1"]) - }) - - it("should create zap split tag with custom split", () => { - const result = tagZapSplit(pubkey1, 0.5) - expect(result).toEqual(["zap", pubkey1, expect.any(String), "0.5"]) - }) - }) - - describe("tagPubkey", () => { - it("should create pubkey tag with relay hint and display name", () => { - const result = tagPubkey(pubkey1) - expect(result).toEqual(["p", pubkey1, expect.any(String), expect.any(String)]) - }) - }) - - describe("tagEvent", () => { - it("should create basic event tag", () => { - const result = tagEvent(mockEvent) - expect(result).toHaveLength(1) - expect(result[0]).toEqual(["e", mockEvent.id, expect.any(String), "", mockEvent.pubkey]) - }) - - it("should include address tag for replaceable events", () => { - const replaceableEvent = {...mockEvent, kind: MUTES} - const result = tagEvent(replaceableEvent) - expect(result).toHaveLength(2) - expect(result[1][0]).toBe("a") - }) - }) - - describe("tagEventPubkeys", () => { - it("should extract and tag unique pubkeys from event", () => { - const event = { - ...mockEvent, - tags: [ - ["p", pubkey1], - ["p", pubkey2], - ], - } - const result = tagEventPubkeys(event) - expect(result).toHaveLength(3) // event.pubkey + 2 tagged pubkeys - expect(result.every(tag => tag[0] === "p")).toBe(true) - }) - }) - - describe("tagEventForQuote", () => { - it("should create quote tag", () => { - const result = tagEventForQuote(mockEvent) - expect(result).toEqual(["q", mockEvent.id, expect.any(String), mockEvent.pubkey]) - }) - }) - - describe("tagEventForReply", () => { - it("should handle reply to event with no existing tags", () => { - const result = tagEventForReply(mockEvent) - expect(result.some(tag => tag[0] === "e")).toBe(true) - expect(result.some(tag => tag[3] === "root")).toBe(true) - }) - - it("should handle reply to event with root", () => { - const eventWithRoot = { - ...mockEvent, - tags: [ - ["e", id1, "", "root"], - ["p", pubkey1], - ], - } - const result = tagEventForReply(eventWithRoot) - const p = result.filter(tag => tag[0] === "p") - const e = result.filter(tag => tag[0] === "e") - // p[0] should be the author of the event - expect(p[0][1]).toBe(pubkey) - // p[1] should be the pubkey mentioned in the event - expect(p[1][1]).toBe(pubkey1) - // e[0] the "e" root tag should have been propagated - expect(e[0][1]).toBe(id1) - // e[1] should be the event id - expect(e[1][1]).toBe(id) - }) - - it("should handle replaceable events", () => { - const replaceableEvent = { - ...mockEvent, - kind: MUTES, - tags: [ - ["e", id1, "relay-url", "root"], - ["e", id2, "relay-url", "mention"], - ], - } - const result = tagEventForReply(replaceableEvent) - - const p = result.filter(tag => tag[0] === "p") - const e = result.filter(tag => tag[0] === "e") - const a = result.filter(tag => tag[0] === "a") - - // p[0] should be the author of the event - expect(p[0][1]).toBe(pubkey) - // e[0] should be the root propagated - expect(e[0][1]).toBe(id1) - expect(e[0][3]).toBe("root") - // e[1] should be the event id and marked as a reply - expect(e[1][1]).toBe(id) - expect(e[1][3]).toBe("reply") - - // a[0] should be the address of the replaceable event - expect(a[0][1]).toBe(getAddress(replaceableEvent)) - }) - }) - - describe("tagEventForComment", () => { - it("should create comment tags for basic event", () => { - const result = tagEventForComment(mockEvent) - expect(result.some(tag => tag[0] === "K")).toBe(true) - expect(result.some(tag => tag[0] === "P")).toBe(true) - expect(result.some(tag => tag[0] === "E")).toBe(true) - }) - - it("should handle replaceable events", () => { - const replaceableEvent = {...mockEvent, kind: MUTES} - const result = tagEventForComment(replaceableEvent) - expect(result.some(tag => tag[0] === "A")).toBe(true) - expect(result.some(tag => tag[0] === "a")).toBe(true) - }) - - it("should preserve root tags and point to the direct parent", () => { - const eventWithTags = { - ...mockEvent, - kind: COMMENT, - tags: [ - ["e", id2, "relay-url", "root"], - ["p", pubkey2, "relay-url"], - ["k", NOTE.toString()], - ["E", id1, "relay-url", "root"], - ["P", pubkey1, "relay-url"], - ["K", NOTE.toString()], - ], - } - const result = tagEventForComment(eventWithTags) - - // Should preserve uppercase variants of existing tags - // expect(result.some(tag => tag[0] === "E" && tag[1] === id1)).toBe(true) - // expect(result.some(tag => tag[0] === "P" && tag[1] === pubkey1)).toBe(true) - // expect(result.some(tag => tag[0] === "K" && tag[1] === NOTE.toString())).toBe(true) - - // Should also add lowercase variants - expect(result.some(tag => tag[0] === "e" && tag[1] === eventWithTags.id)).toBe(true) - expect(result.some(tag => tag[0] === "p" && tag[1] === eventWithTags.pubkey)).toBe(true) - expect(result.some(tag => tag[0] === "k" && tag[1] === COMMENT.toString())).toBe(true) - }) - - it("should handle events with multiple root tags", () => { - const eventWithMultipleRoots = { - ...mockEvent, - tags: [ - ["e", id1, "relay-url", "root"], - ["e", id2, "relay-url", "root"], - ], - } - const result = tagEventForComment(eventWithMultipleRoots) - - expect(result).toEqual([ - ["K", String(NOTE)], - ["P", pubkey, expect.any(String)], - ["E", id, expect.any(String), pubkey], - ["k", String(NOTE)], - ["p", pubkey, expect.any(String)], - ["e", id, expect.any(String), pubkey], - ]) - }) - - it("should handle events with mixed tag types", () => { - const eventWithMixedTags = { - ...mockEvent, - kind: MUTES, - tags: [ - ["e", id, "relay-url", "root"], - ["p", pubkey1, "relay-url"], - ["i", id1], - ["a", "some-address", "relay-url"], - ["custom", "value"], - ], - } - const result = tagEventForComment(eventWithMixedTags) - - expect(result).toEqual([ - ["K", String(MUTES)], - ["P", pubkey, expect.any(String)], - ["E", id, expect.any(String), pubkey], - ["A", getAddress(eventWithMixedTags), expect.any(String), pubkey], - ["k", String(MUTES)], - ["p", pubkey, expect.any(String)], - ["e", id, expect.any(String), pubkey], - ["a", getAddress(eventWithMixedTags), expect.any(String), pubkey], - ]) - }) - - it("should add event metadata tags when no root tags exist", () => { - const eventWithoutRoots = { - ...mockEvent, - tags: [["custom", "value"]], - } - const result = tagEventForComment(eventWithoutRoots) - - // Should add uppercase metadata tags (roots) - expect(result.some(tag => tag[0] === "K" && tag[1] === String(mockEvent.kind))).toBe(true) - expect(result.some(tag => tag[0] === "P" && tag[1] === mockEvent.pubkey)).toBe(true) - expect(result.some(tag => tag[0] === "E" && tag[1] === mockEvent.id)).toBe(true) - - // Should add lowercase variants (parents) - expect(result.some(tag => tag[0] === "k" && tag[1] === String(mockEvent.kind))).toBe(true) - expect(result.some(tag => tag[0] === "p" && tag[1] === mockEvent.pubkey)).toBe(true) - expect(result.some(tag => tag[0] === "e" && tag[1] === mockEvent.id)).toBe(true) - }) - }) - - describe("tagEventForReaction", () => { - it("should create reaction tags", () => { - const result = tagEventForReaction(mockEvent) - expect(result.some(tag => tag[0] === "k")).toBe(true) - expect(result.some(tag => tag[0] === "e")).toBe(true) - }) - - it("should include author tag if different from current user", () => { - const result = tagEventForReaction(mockEvent) - expect(result.some(tag => tag[0] === "p")).toBe(true) - }) - - it("should handle replaceable events", () => { - const replaceableEvent = {...mockEvent, kind: MUTES} - const result = tagEventForReaction(replaceableEvent) - expect(result.some(tag => tag[0] === "a")).toBe(true) - }) - }) -}) diff --git a/packages/app/__tests__/thunk.test.ts b/packages/app/__tests__/thunk.test.ts deleted file mode 100644 index f239dbb..0000000 --- a/packages/app/__tests__/thunk.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {PublishStatus, LOCAL_RELAY_URL} from "@welshman/net" -import {NOTE, DIRECT_MESSAGE, WRAP, makeEvent, getPubkey, makeSecret, prep} from "@welshman/util" -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -import {repository, tracker} from "../src/core" -import {addSession, dropSession, makeNip01Session} from "../src/session" -import {abortThunk, MergedThunk, publishThunk, thunkQueue, flattenThunks} from "../src/thunk" - -const secret = makeSecret() - -const pubkey = getPubkey(secret) - -const mockRequest = { - event: prep({...makeEvent(NOTE), pubkey}), - relays: [LOCAL_RELAY_URL], -} - -describe("thunk", () => { - beforeEach(() => { - vi.useFakeTimers() - addSession(makeNip01Session(secret)) - }) - - afterEach(async () => { - thunkQueue.stop() - thunkQueue.clear() - await vi.runAllTimersAsync() - vi.useRealTimers() - vi.clearAllMocks() - thunkQueue.start() - dropSession(pubkey) - }) - - describe("MergedThunk", () => { - it("should abort all thunks when merged controller aborts", () => { - const thunk1 = publishThunk(mockRequest) - const thunk2 = publishThunk(mockRequest) - const merged = new MergedThunk([thunk1, thunk2]) - - abortThunk(merged) - - expect(thunk1.controller.signal.aborted).toBe(true) - expect(thunk2.controller.signal.aborted).toBe(true) - }) - }) - - describe("flattenThunks", () => { - it("should iterate through nested thunks", () => { - const thunk1 = publishThunk(mockRequest) - const thunk2 = publishThunk(mockRequest) - const merged = new MergedThunk([thunk1, thunk2]) - const thunks = Array.from(flattenThunks([merged, thunk1])) - - expect(thunks).toHaveLength(3) - }) - }) - - describe("publishThunk", () => { - it("should create and publish a thunk", async () => { - const publishSpy = vi.spyOn(repository, "publish") - const result = publishThunk(mockRequest) - - expect(publishSpy).toHaveBeenCalled() - expect(result).toHaveProperty("event") - expect(result).toHaveProperty("options") - }) - - it("should handle abort", () => { - const removeEventSpy = vi.spyOn(repository, "removeEvent") - const thunk = publishThunk(mockRequest) - - abortThunk(thunk) - - expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id) - }) - }) - - describe("abortThunk", () => { - it("should abort a thunk and clean up", () => { - const removeEventSpy = vi.spyOn(repository, "removeEvent") - const thunk = publishThunk(mockRequest) - - abortThunk(thunk) - - expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id) - }) - }) - - it("should update status during publishing", async () => { - const track = vi.spyOn(tracker, "track") - const thunk = publishThunk(mockRequest) - - // Wait for initial async operations - await vi.runAllTimersAsync() - - expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success) - - // Verify tracker was called on success - expect(track).toHaveBeenCalledWith(thunk.event.id, LOCAL_RELAY_URL) - - await vi.runAllTimersAsync() - await thunk.complete - - expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success) - }) - - describe("wrapped events", () => { - it("if recipient is included, the event should be wrapped", async () => { - const recipient = getPubkey(makeSecret()) - const event = prep({...makeEvent(DIRECT_MESSAGE), pubkey}) - const thunk = publishThunk({event, relays: [], recipient}) - const publishSpy = vi.spyOn(thunk, "_publish") - - await vi.runAllTimersAsync() - - expect(publishSpy.mock.calls[0][0].kind).toBe(WRAP) - expect(publishSpy.mock.calls[0][0].id).not.toBe(thunk.event.id) - }) - }) -}) diff --git a/packages/app/package.json b/packages/app/package.json index e570f57..e15c26a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,9 +1,9 @@ { "name": "@welshman/app", - "version": "0.8.16", + "version": "0.8.13", "author": "hodlbod", "license": "MIT", - "description": "A collection of svelte stores for use in building nostr client applications.", + "description": "An instance-based, composable client for building nostr applications", "publishConfig": { "access": "public" }, diff --git a/packages/client/src/client.ts b/packages/app/src/app.ts similarity index 78% rename from packages/client/src/client.ts rename to packages/app/src/app.ts index 38a9dfa..3cf082e 100644 --- a/packages/client/src/client.ts +++ b/packages/app/src/app.ts @@ -3,26 +3,26 @@ import {call} from "@welshman/lib" import {Pool, Tracker, Repository, WrapManager} from "@welshman/net" import type {NetContext, AdapterFactory} from "@welshman/net" import type {User} from "./user.js" -import type {ClientPolicy} from "./policy.js" +import type {AppPolicy} from "./policy.js" -export type ClientConfig = { +export type AppConfig = { dufflepudUrl?: string getDefaultRelays?: () => string[] getIndexerRelays?: () => string[] getSearchRelays?: () => string[] } -export type ClientOptions = { +export type AppOptions = { user?: User - config?: ClientConfig + config?: AppConfig getAdapter?: AdapterFactory - policies?: ClientPolicy[] + policies?: AppPolicy[] } -export interface IClient { +export interface IApp { user?: User - config: ClientConfig - use: (Ctor: new (ctx: IClient) => T) => T + config: AppConfig + use: (Ctor: new (app: IApp) => T) => T netContext: NetContext pool: Pool tracker: Tracker @@ -36,9 +36,9 @@ export interface IClient { * pool, a tracker, a wrap manager — and a `use` registry that resolves data * modules (including net/store helpers) on demand. */ -export class Client implements IClient { +export class App implements IApp { user?: User - config: ClientConfig + config: AppConfig netContext: NetContext pool: Pool tracker: Tracker @@ -48,7 +48,7 @@ export class Client implements IClient { private singletons = new Map() private unsubscribers: Unsubscriber[] = [] - constructor(options: ClientOptions = {}) { + constructor(options: AppOptions = {}) { this.user = options.user this.config = options.config ?? {} this.pool = new Pool() @@ -69,10 +69,10 @@ export class Client implements IClient { } } - // Resolve the per-client singleton of a data module, constructing it on first - // use. This is how modules reach their dependencies (e.g. ctx.use(RelayLists)), + // Resolve the per-app singleton of a data module, constructing it on first + // use. This is how modules reach their dependencies (e.g. app.use(RelayLists)), // replacing constructor injection and letting cycles resolve lazily. - use = (Ctor: new (ctx: IClient) => T): T => { + use = (Ctor: new (app: IApp) => T): T => { let instance = this.singletons.get(Ctor) as T | undefined if (!instance) { diff --git a/packages/app/src/blockedRelayLists.ts b/packages/app/src/blockedRelayLists.ts deleted file mode 100644 index 8bc49b8..0000000 --- a/packages/app/src/blockedRelayLists.ts +++ /dev/null @@ -1,42 +0,0 @@ -import {BLOCKED_RELAYS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const blockedRelayListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [BLOCKED_RELAYS]}], - getKey: blockedRelayLists => blockedRelayLists.event.pubkey, -}) - -export const blockedRelayLists = deriveItems(blockedRelayListsByPubkey) - -export const getBlockedRelayListsByPubkey = getter(blockedRelayListsByPubkey) - -export const getBlockedRelayLists = getter(blockedRelayLists) - -export const getBlockedRelayList = (pubkey: string) => getBlockedRelayListsByPubkey().get(pubkey) - -export const forceLoadBlockedRelayList = makeForceLoadItem( - makeOutboxLoader(BLOCKED_RELAYS), - getBlockedRelayList, -) - -export const loadBlockedRelayList = makeLoadItem( - makeOutboxLoader(BLOCKED_RELAYS), - getBlockedRelayList, -) - -export const deriveBlockedRelayList = makeDeriveItem( - blockedRelayListsByPubkey, - loadBlockedRelayList, -) diff --git a/packages/app/src/blossom.ts b/packages/app/src/blossom.ts deleted file mode 100644 index e5a287a..0000000 --- a/packages/app/src/blossom.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const blossomServerListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [BLOSSOM_SERVERS]}], - getKey: blossomServerList => blossomServerList.event.pubkey, -}) - -export const blossomServerLists = deriveItems(blossomServerListsByPubkey) - -export const getBlossomServerListsByPubkey = getter(blossomServerListsByPubkey) - -export const getBlossomServerList = (pubkey: string) => getBlossomServerListsByPubkey().get(pubkey) - -export const forceLoadBlossomServerList = makeForceLoadItem( - makeOutboxLoader(BLOSSOM_SERVERS), - getBlossomServerList, -) - -export const loadBlossomServerList = makeLoadItem( - makeOutboxLoader(BLOSSOM_SERVERS), - getBlossomServerList, -) - -export const deriveBlossomServerList = makeDeriveItem( - blossomServerListsByPubkey, - loadBlossomServerList, -) diff --git a/packages/app/src/commands.ts b/packages/app/src/commands.ts deleted file mode 100644 index 94e78e5..0000000 --- a/packages/app/src/commands.ts +++ /dev/null @@ -1,378 +0,0 @@ -import {get} from "svelte/store" -import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib" -import { - sendManagementRequest, - ManagementRequest, - addToListPublicly, - addToListPrivately, - updateList, - EventTemplate, - removeFromList, - makeHttpAuth, - getListTags, - getRelayTags, - getRelayTagValues, - getRelaysFromList, - makeList, - makeRoomCreateEvent, - makeRoomDeleteEvent, - makeRoomEditEvent, - makeRoomJoinEvent, - makeRoomLeaveEvent, - makeRoomAddMemberEvent, - makeRoomRemoveMemberEvent, - isPublishedProfile, - createProfile, - editProfile, - RelayMode, - makeEvent, - MESSAGING_RELAYS, - BLOCKED_RELAYS, - SEARCH_RELAYS, - FOLLOWS, - RELAYS, - MUTES, - PINS, - prep, -} from "@welshman/util" -import type {RoomMeta, Profile} from "@welshman/util" -import {Router, addMaximalFallbacks} from "@welshman/router" -import { - userRelayList, - forceLoadUserRelayList, - userMessagingRelayList, - forceLoadUserMessagingRelayList, - userBlockedRelayList, - forceLoadUserBlockedRelayList, - userSearchRelayList, - forceLoadUserSearchRelayList, - userFollowList, - forceLoadUserFollowList, - userMuteList, - forceLoadUserMuteList, - userPinList, - forceLoadUserPinList, -} from "./user.js" -import {nip44EncryptToSelf, signer, pubkey} from "./session.js" -import {ThunkOptions, MergedThunk, publishThunk} from "./thunk.js" -import {loadMessagingRelayList} from "./messagingRelayLists.js" - -// NIP 65 - -export const removeRelay = async (url: string, mode: RelayMode) => { - await forceLoadUserRelayList([]) - - const list = get(userRelayList) || makeList({kind: RELAYS}) - const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) - const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read - const tags = list.publicTags.filter(nthNe(1, url)) - - // If we had a duplicate that was used as the alt mode, keep the alt - if (dup && (!dup[2] || dup[2] === alt)) { - tags.push(["r", url, alt]) - } - - const event = {kind: list.kind, content: list.event?.content || "", tags} - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - // Make sure to notify the old relay too - relays.push(url) - - return publishThunk({event, relays}) -} - -export const addRelay = async (url: string, mode: RelayMode) => { - await forceLoadUserRelayList([]) - - const list = get(userRelayList) || makeList({kind: RELAYS}) - const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) - const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode]) - const tags = [...list.publicTags.filter(nthNe(1, url)), tag] - const event = {kind: list.kind, content: list.event?.content || "", tags} - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setRelays = async (tags: string[][]) => { - const router = Router.get() - const event = makeEvent(RELAYS, {tags}) - const relays = router - .merge([router.Index(), router.FromRelays(getRelayTagValues(tags))]) - .getUrls() - - return publishThunk({event, relays}) -} - -export const setReadRelays = async (urls: string[]) => { - await forceLoadUserRelayList([]) - - const list = get(userRelayList) || makeList({kind: RELAYS}) - const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1)) - const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write]) - const readTags = urls.map(url => ["r", url, RelayMode.Read]) - const tags = [...writeTags, ...readTags] - const event = {kind: list.kind, content: list.event?.content || "", tags} - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setWriteRelays = async (urls: string[]) => { - await forceLoadUserRelayList([]) - - const list = get(userRelayList) || makeList({kind: RELAYS}) - const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1)) - const readTags = readRelays.map(url => ["r", url, RelayMode.Read]) - const writeTags = urls.map(url => ["r", url, RelayMode.Write]) - const tags = [...readTags, ...writeTags] - const event = {kind: list.kind, content: list.event?.content || "", tags} - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -// NIP 17 - -export const removeMessagingRelay = async (url: string) => { - await forceLoadUserMessagingRelayList([]) - - const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS}) - const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const addMessagingRelay = async (url: string) => { - await forceLoadUserMessagingRelayList([]) - - const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS}) - const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setMessagingRelays = async (urls: string[]) => { - const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}) - const relays = Router.get().FromUser().getUrls() - - return publishThunk({event, relays}) -} - -// Blocked Relays - -export const removeBlockedRelay = async (url: string) => { - await forceLoadUserBlockedRelayList([]) - - const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS}) - const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const addBlockedRelay = async (url: string) => { - await forceLoadUserBlockedRelayList([]) - - const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS}) - const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setBlockedRelays = async (urls: string[]) => { - const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}) - const relays = Router.get().FromUser().getUrls() - - return publishThunk({event, relays}) -} - -// Search Relays - -export const removeSearchRelay = async (url: string) => { - await forceLoadUserSearchRelayList([]) - - const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS}) - const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const addSearchRelay = async (url: string) => { - await forceLoadUserSearchRelayList([]) - - const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS}) - const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setSearchRelays = async (urls: string[]) => { - const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}) - const relays = Router.get().FromUser().getUrls() - - return publishThunk({event, relays}) -} - -// NIP 01 - -export const setProfile = (profile: Profile) => { - const router = Router.get() - const relays = router.merge([router.Index(), router.FromUser()]).getUrls() - const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) - - return publishThunk({event, relays}) -} - -// NIP 02 - -export const unfollow = async (value: string) => { - await forceLoadUserFollowList([]) - - const list = get(userFollowList) || makeList({kind: FOLLOWS}) - const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const follow = async (tag: string[]) => { - await forceLoadUserFollowList([]) - - const list = get(userFollowList) || makeList({kind: FOLLOWS}) - const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const unmute = async (value: string) => { - await forceLoadUserMuteList([]) - - const list = get(userMuteList) || makeList({kind: MUTES}) - const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const mutePublicly = async (tag: string[]) => { - await forceLoadUserMuteList([]) - - const list = get(userMuteList) || makeList({kind: MUTES}) - const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const mutePrivately = async (tag: string[]) => { - await forceLoadUserMuteList([]) - - const list = get(userMuteList) || makeList({kind: MUTES}) - const event = await addToListPrivately(list, tag).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const setMutes = async ({ - publicTags, - privateTags, -}: { - publicTags?: string[][] - privateTags?: string[][] -}) => { - await forceLoadUserMuteList([]) - - const list = get(userMuteList) || makeList({kind: MUTES}) - const event = await updateList(list, {publicTags, privateTags}).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const unpin = async (value: string) => { - await forceLoadUserPinList([]) - - const list = get(userPinList) || makeList({kind: PINS}) - const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -export const pin = async (tag: string[]) => { - await forceLoadUserPinList([]) - - const list = get(userPinList) || makeList({kind: PINS}) - const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls() - - return publishThunk({event, relays}) -} - -// NIP 59 - -export type SendWrappedOptions = Omit & { - event: EventTemplate - recipients: string[] -} - -export const sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => { - const $pubkey = pubkey.get() - - // Stabilize the event id across different wraps - if ($pubkey) { - event = prep(event, $pubkey, now()) - } - - return new MergedThunk( - await Promise.all( - uniq(recipients).map(async recipient => { - const relays = getRelaysFromList(await loadMessagingRelayList(recipient)) - - return publishThunk({event, relays, recipient, ...options}) - }), - ), - ) -} - -// NIP 86 - -export const manageRelay = async (url: string, request: ManagementRequest) => { - url = url.replace(/^ws/, "http") - - const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request)) - const authEvent = await signer.get()!.sign(authTemplate) - - return sendManagementRequest(url, request, authEvent) -} - -// NIP 29 - -export const createRoom = (url: string, room: RoomMeta) => - publishThunk({event: makeRoomCreateEvent(room), relays: [url]}) - -export const deleteRoom = (url: string, room: RoomMeta) => - publishThunk({event: makeRoomDeleteEvent(room), relays: [url]}) - -export const editRoom = (url: string, room: RoomMeta) => - publishThunk({event: makeRoomEditEvent(room), relays: [url]}) - -export const joinRoom = (url: string, room: RoomMeta) => - publishThunk({event: makeRoomJoinEvent(room), relays: [url]}) - -export const leaveRoom = (url: string, room: RoomMeta) => - publishThunk({event: makeRoomLeaveEvent(room), relays: [url]}) - -export const addRoomMember = (url: string, room: RoomMeta, pubkey: string) => - publishThunk({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]}) - -export const removeRoomMember = (url: string, room: RoomMeta, pubkey: string) => - publishThunk({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]}) diff --git a/packages/app/src/context.ts b/packages/app/src/context.ts deleted file mode 100644 index f198ae0..0000000 --- a/packages/app/src/context.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type AppContext = { - dufflepudUrl?: string -} - -export const appContext: AppContext = {} diff --git a/packages/app/src/core.ts b/packages/app/src/core.ts deleted file mode 100644 index b968a7f..0000000 --- a/packages/app/src/core.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {Repository, Tracker} from "@welshman/net" - -export const tracker = new Tracker() - -export const repository = Repository.get() diff --git a/packages/app/src/createApp.ts b/packages/app/src/createApp.ts new file mode 100644 index 0000000..6e7354d --- /dev/null +++ b/packages/app/src/createApp.ts @@ -0,0 +1,14 @@ +import {App} from "./app.js" +import type {AppOptions} from "./app.js" +import {defaultAppPolicies} from "./policy.js" + +/** + * Creates a batteries-included app: an `App` wired with the default app + * policies (event ingestion, relay-stats collection, gift-wrap unwrapping). + * Reach data modules via `app.use(Profiles)`, `app.use(FollowLists)`, etc. + * + * For a bare app (no default side effects) construct `new App(...)` + * directly, or pass your own `policies`. + */ +export const createApp = (options: AppOptions = {}) => + new App({...options, policies: options.policies ?? defaultAppPolicies}) diff --git a/packages/app/src/feeds.ts b/packages/app/src/feeds.ts deleted file mode 100644 index 564e2f6..0000000 --- a/packages/app/src/feeds.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {Scope, FeedController, FeedControllerOptions, Feed} from "@welshman/feeds" -import {pubkey, signer} from "./session.js" -import {getWotGraph, getMaxWot, getFollows, getNetwork, getFollowers} from "./wot.js" - -export const getPubkeysForScope = (scope: string) => { - const $pubkey = pubkey.get() - - if (!$pubkey) { - return [] - } - - switch (scope) { - case Scope.Self: - return [$pubkey] - case Scope.Follows: - return getFollows($pubkey) - case Scope.Network: - return getNetwork($pubkey) - case Scope.Followers: - return getFollowers($pubkey) - default: - return [] - } -} - -export const getPubkeysForWOTRange = (min: number, max: number) => { - const pubkeys = [] - const $maxWot = getMaxWot() - const thresholdMin = $maxWot * min - const thresholdMax = $maxWot * max - - for (const [tpk, score] of getWotGraph().entries()) { - if (score >= thresholdMin && score <= thresholdMax) { - pubkeys.push(tpk) - } - } - - return pubkeys -} - -type MakeFeedControllerOptions = Partial> & {feed: Feed} - -export const makeFeedController = (options: MakeFeedControllerOptions) => - new FeedController({getPubkeysForScope, getPubkeysForWOTRange, signer: signer.get(), ...options}) diff --git a/packages/app/src/follows.ts b/packages/app/src/follows.ts deleted file mode 100644 index 446ce72..0000000 --- a/packages/app/src/follows.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const followListsByPubkey = deriveItemsByKey({ - repository, - filters: [{kinds: [FOLLOWS]}], - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - getKey: followList => followList.event.pubkey, -}) - -export const followLists = deriveItems(followListsByPubkey) - -export const getFollowListsByPubkey = getter(followListsByPubkey) - -export const getFollowLists = getter(followLists) - -export const getFollowList = (pubkey: string) => getFollowListsByPubkey().get(pubkey) - -export const forceLoadFollowList = makeForceLoadItem(makeOutboxLoader(FOLLOWS), getFollowList) - -export const loadFollowList = makeLoadItem(makeOutboxLoader(FOLLOWS), getFollowList) - -export const deriveFollowList = makeDeriveItem(followListsByPubkey, loadFollowList) diff --git a/packages/app/src/handles.ts b/packages/app/src/handles.ts deleted file mode 100644 index 3f3eb56..0000000 --- a/packages/app/src/handles.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {writable, Subscriber} from "svelte/store" -import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib" -import { - getter, - deriveItems, - deriveDeduplicated, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, -} from "@welshman/store" -import {deriveProfile, loadProfile} from "./profiles.js" -import {appContext} from "./context.js" - -export type Handle = { - nip05: string - pubkey?: string - nip46?: string[] - relays?: string[] -} - -export async function queryProfile(nip05: string) { - const parts = nip05.split("@") - const name = parts.length > 1 ? parts[0] : "_" - const domain = last(parts) - - try { - const { - names, - relays = {}, - nip46 = {}, - } = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`) - - const pubkey = names[name] - - if (!pubkey) { - return undefined - } - - return { - nip05, - pubkey, - nip46: nip46[pubkey], - relays: relays[pubkey], - } - } catch (_e) { - return undefined - } -} - -export const handlesByNip05 = writable(new Map()) - -export const handles = deriveItems(handlesByNip05) - -export const getHandlesByNip05 = getter(handlesByNip05) - -export const getHandles = getter(handles) - -export const getHandle = (nip05: string) => getHandlesByNip05().get(nip05) - -export const handleSubscribers: Subscriber[] = [] - -export const notifyHandle = (handle: Handle) => handleSubscribers.forEach(sub => sub(handle)) - -export const onHandle = (sub: (handle: Handle) => void) => { - handleSubscribers.push(sub) - - return () => { - const i = handleSubscribers.findIndex(s => s === sub) - - if (i !== -1) handleSubscribers.splice(i, 1) - } -} - -export const fetchHandle = batcher(800, async (nip05s: string[]) => { - const result = new Map() - - // Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly - if (appContext.dufflepudUrl) { - const res: any = await tryCatch( - async () => await postJson(`${appContext.dufflepudUrl}/handle/info`, {handles: nip05s}), - ) - - for (const {handle: nip05, info} of res?.data || []) { - if (info) { - result.set(nip05, {...info, nip05}) - } - } - } else { - const results = await Promise.all( - nip05s.map(async nip05 => ({ - nip05, - info: await tryCatch(async () => await queryProfile(nip05)), - })), - ) - - for (const {nip05, info} of results) { - if (info) { - result.set(nip05, {...info, nip05}) - } - } - } - - handlesByNip05.update($handlesByNip05 => { - for (const [nip05, info] of result) { - $handlesByNip05.set(nip05, info) - } - - return $handlesByNip05 - }) - - for (const info of result.values()) { - notifyHandle(info) - } - - return nip05s.map(nip05 => result.get(nip05)) -}) - -export const forceLoadHandle = makeForceLoadItem(fetchHandle, getHandle) - -export const loadHandle = makeLoadItem(fetchHandle, getHandle) - -export const deriveHandle = makeDeriveItem(handlesByNip05, loadHandle) - -export const loadHandleForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await loadProfile(pubkey, relays) - - return $profile?.nip05 ? loadHandle($profile.nip05) : undefined -} - -export const deriveHandleForPubkey = (pubkey: string, relays: string[] = []) => { - loadHandleForPubkey(pubkey, relays) - - return deriveDeduplicated( - [handlesByNip05, deriveProfile(pubkey, relays)], - ([$handlesByNip05, $profile]) => { - if (!$profile?.nip05) return undefined - - const handle = $handlesByNip05.get($profile.nip05) - - if (handle?.pubkey !== pubkey) return undefined - - return handle - }, - ) -} - -export const displayNip05 = (nip05: string) => - nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05 - -export const displayHandle = (handle: Handle) => displayNip05(handle.nip05) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index fa70719..9ca48a2 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,112 +1,34 @@ -export * from "./blossom.js" -export * from "./context.js" -export * from "./core.js" -export * from "./commands.js" -export * from "./feeds.js" -export * from "./follows.js" -export * from "./handles.js" -export * from "./mutes.js" -export * from "./plaintext.js" -export * from "./profiles.js" -export * from "./pins.js" -export * from "./relays.js" -export * from "./relayStats.js" -export * from "./relayLists.js" -export * from "./blockedRelayLists.js" -export * from "./messagingRelayLists.js" -export * from "./search.js" -export * from "./session.js" -export * from "./sync.js" -export * from "./tags.js" -export * from "./thunk.js" -export * from "./topics.js" +export * from "./app.js" +export * from "./policy.js" export * from "./user.js" -export * from "./wot.js" -export * from "./zappers.js" - -import {derived} from "svelte/store" -import {sortBy, throttleWithValue} from "@welshman/lib" -import { - isEphemeralKind, - isDVMKind, - WRAP, - RelayMode, - RelayProfile, - getRelaysFromList, -} from "@welshman/util" -import {routerContext} from "@welshman/router" -import {Pool, SocketEvent, isRelayEvent, netContext} from "@welshman/net" -import {pubkey, unwrapAndStore} from "./session.js" -import {repository, tracker} from "./core.js" -import {getRelays, loadRelay} from "./relays.js" -import {trackRelayStats, getRelayQuality} from "./relayStats.js" -import {deriveRelayList, getRelayList} from "./relayLists.js" -import {deriveSearchRelayList, getSearchRelayList} from "./searchRelayLists.js" -import {deriveBlockedRelayList, getBlockedRelayList} from "./blockedRelayLists.js" -import {deriveMessagingRelayList, getMessagingRelayList} from "./messagingRelayLists.js" - -// Sync relays with our database - -Pool.get().subscribe(socket => { - loadRelay(socket.url) - trackRelayStats(socket) - - socket.on(SocketEvent.Receive, message => { - if (isRelayEvent(message)) { - const event = message[2] - - if ( - !isDVMKind(event.kind) && - !isEphemeralKind(event.kind) && - netContext.isEventValid(event, socket.url) - ) { - tracker.track(event.id, socket.url) - - if (event.kind === WRAP) { - unwrapAndStore(event) - } else { - repository.publish(event) - } - } - } - }) -}) - -// Configure the router and add a few other relay utils - -const _relayGetter = (fn?: (relay: RelayProfile) => any) => - throttleWithValue(200, () => { - let _relays = getRelays() - - if (fn) { - _relays = _relays.filter(fn) - } - - return sortBy(r => -getRelayQuality(r.url), _relays) - .slice(0, 5) - .map(r => r.url) - }) - -export const getPubkeyRelays = (pubkey: string, mode?: RelayMode) => { - if (mode === RelayMode.Search) return getRelaysFromList(getSearchRelayList(pubkey)) - if (mode === RelayMode.Blocked) return getRelaysFromList(getBlockedRelayList(pubkey)) - if (mode === RelayMode.Messaging) return getRelaysFromList(getMessagingRelayList(pubkey)) - return getRelaysFromList(getRelayList(pubkey), mode) -} - -export const derivePubkeyRelays = (pubkey: string, mode?: RelayMode) => { - if (mode === RelayMode.Search) - return derived(deriveSearchRelayList(pubkey), list => getRelaysFromList(list)) - if (mode === RelayMode.Blocked) - return derived(deriveBlockedRelayList(pubkey), list => getRelaysFromList(list)) - if (mode === RelayMode.Messaging) - return derived(deriveMessagingRelayList(pubkey), list => getRelaysFromList(list)) - return derived(deriveRelayList(pubkey), list => getRelaysFromList(list, mode)) -} - -routerContext.getUserPubkey = () => pubkey.get() -routerContext.getPubkeyRelays = getPubkeyRelays -routerContext.getRelayQuality = getRelayQuality -routerContext.getDefaultRelays = _relayGetter() -routerContext.getIndexerRelays = _relayGetter() -routerContext.getSearchRelays = _relayGetter(r => r?.supported_nips?.includes?.("50")) +export * from "./session.js" +export * from "./logging.js" +export * from "./createApp.js" +export * from "./plugins/base.js" +export * from "./plugins/network.js" +export * from "./plugins/stores.js" +export * from "./plugins/router.js" +export * from "./plugins/relays.js" +export * from "./plugins/relayStats.js" +export * from "./plugins/relayLists.js" +export * from "./plugins/blockedRelayLists.js" +export * from "./plugins/plaintext.js" +export * from "./plugins/profiles.js" +export * from "./plugins/follows.js" +export * from "./plugins/mutes.js" +export * from "./plugins/pins.js" +export * from "./plugins/blossom.js" +export * from "./plugins/messagingRelayLists.js" +export * from "./plugins/searchRelayLists.js" +export * from "./plugins/handles.js" +export * from "./plugins/zappers.js" +export * from "./plugins/topics.js" +export * from "./plugins/tags.js" +export * from "./plugins/wot.js" +export * from "./plugins/feeds.js" +export * from "./plugins/search.js" +export * from "./plugins/sync.js" +export * from "./plugins/wraps.js" +export * from "./plugins/rooms.js" +export * from "./plugins/relayManagement.js" +export * from "./plugins/thunk.js" diff --git a/packages/client/src/logging.ts b/packages/app/src/logging.ts similarity index 94% rename from packages/client/src/logging.ts rename to packages/app/src/logging.ts index 5bcc5fe..6db2db8 100644 --- a/packages/client/src/logging.ts +++ b/packages/app/src/logging.ts @@ -21,7 +21,7 @@ export type LogMessage = /** * An `ISigner` wrapper that emits a structured `LogMessage` (as a "message" * event on itself) for every operation it performs. `User.fromSigner` wraps - * signers in this so they're observable; subscribe via `makeClientPolicyLogger`. + * signers in this so they're observable; subscribe via `makeAppPolicyLogger`. */ export class LoggingSigner extends WrappedSigner { constructor(signer: ISigner) { diff --git a/packages/app/src/messagingRelayLists.ts b/packages/app/src/messagingRelayLists.ts deleted file mode 100644 index 526e867..0000000 --- a/packages/app/src/messagingRelayLists.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const messagingRelayListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [MESSAGING_RELAYS]}], - getKey: messagingRelayLists => messagingRelayLists.event.pubkey, -}) - -export const messagingRelayLists = deriveItems(messagingRelayListsByPubkey) - -export const getMessagingRelayListsByPubkey = getter(messagingRelayListsByPubkey) - -export const getMessagingRelayLists = getter(messagingRelayLists) - -export const getMessagingRelayList = (pubkey: string) => - getMessagingRelayListsByPubkey().get(pubkey) - -export const forceLoadMessagingRelayList = makeForceLoadItem( - makeOutboxLoader(MESSAGING_RELAYS), - getMessagingRelayList, -) - -export const loadMessagingRelayList = makeLoadItem( - makeOutboxLoader(MESSAGING_RELAYS), - getMessagingRelayList, -) - -export const deriveMessagingRelayList = makeDeriveItem( - messagingRelayListsByPubkey, - loadMessagingRelayList, -) diff --git a/packages/app/src/mutes.ts b/packages/app/src/mutes.ts deleted file mode 100644 index 68aa2e6..0000000 --- a/packages/app/src/mutes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {MUTES, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent, PublishedList} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {ensurePlaintext} from "./plaintext.js" -import {getSession} from "./session.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const muteListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: async (event: TrustedEvent) => { - const content = await ensurePlaintext(event) - - // If this is our own mute list (we have a session for it) but it couldn't be - // decrypted yet because no signer is available, don't cache a result with empty - // private tags — that would get stuck permanently since deriveItemsByKey won't - // re-process an already-seen event id. Returning undefined leaves it uncached so it's - // retried once a signer is available. For other pubkeys' lists (no session) we fall - // through and read just the public tags, as before. - if (event.content && content === undefined && getSession(event.pubkey)) { - return undefined - } - - return readList(asDecryptedEvent(event, {content})) - }, - filters: [{kinds: [MUTES]}], - getKey: mute => mute.event.pubkey, -}) - -export const muteLists = deriveItems(muteListsByPubkey) - -export const getMuteListsByPubkey = getter(muteListsByPubkey) - -export const getMuteLists = getter(muteLists) - -export const getMuteList = (pubkey: string) => getMuteListsByPubkey().get(pubkey) - -export const forceLoadMuteList = makeForceLoadItem(makeOutboxLoader(MUTES), getMuteList) - -export const loadMuteList = makeLoadItem(makeOutboxLoader(MUTES), getMuteList) - -export const deriveMuteList = makeDeriveItem(muteListsByPubkey, loadMuteList) diff --git a/packages/app/src/pins.ts b/packages/app/src/pins.ts deleted file mode 100644 index a081651..0000000 --- a/packages/app/src/pins.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {PINS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const pinListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [PINS]}], - getKey: pins => pins.event.pubkey, -}) - -export const pinLists = deriveItems(pinListsByPubkey) - -export const getPinListsByPubkey = getter(pinListsByPubkey) - -export const getPinLists = getter(pinLists) - -export const getPinList = (pubkey: string) => getPinListsByPubkey().get(pubkey) - -export const forceLoadPinList = makeForceLoadItem(makeOutboxLoader(PINS), getPinList) - -export const loadPinList = makeLoadItem(makeOutboxLoader(PINS), getPinList) - -export const derivePinList = makeDeriveItem(pinListsByPubkey, loadPinList) diff --git a/packages/app/src/plaintext.ts b/packages/app/src/plaintext.ts deleted file mode 100644 index 5df4746..0000000 --- a/packages/app/src/plaintext.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {writable} from "svelte/store" -import {assoc} from "@welshman/lib" -import type {TrustedEvent} from "@welshman/util" -import {withGetter} from "@welshman/store" -import {decrypt} from "@welshman/signer" -import {getSigner, getSession} from "./session.js" - -export const plaintext = withGetter(writable>({})) - -export const getPlaintext = (e: TrustedEvent) => plaintext.get()[e.id] - -export const setPlaintext = (e: TrustedEvent, content: string) => - plaintext.update(assoc(e.id, content)) - -export const ensurePlaintext = async (e: TrustedEvent) => { - // Check for key presence rather than truthiness so a legitimately empty decrypted - // result ("") is treated as cached and we don't re-decrypt (and re-hit the signer) on - // every call. - if (e.content && plaintext.get()[e.id] === undefined) { - const $session = getSession(e.pubkey) - - if (!$session) return - - const $signer = getSigner($session) - - if (!$signer) return - - let result - - try { - result = await decrypt($signer, e.pubkey, e.content) - } catch (e: any) { - if (!String(e).match(/invalid base64/)) { - throw e - } - } - - if (result !== undefined) { - setPlaintext(e, result) - } - } - - return getPlaintext(e) -} diff --git a/packages/client/src/plugins/base.ts b/packages/app/src/plugins/base.ts similarity index 94% rename from packages/client/src/plugins/base.ts rename to packages/app/src/plugins/base.ts index 5ba3af6..2a9027a 100644 --- a/packages/client/src/plugins/base.ts +++ b/packages/app/src/plugins/base.ts @@ -4,7 +4,7 @@ import type {Maybe} from "@welshman/lib" import type {Filter} from "@welshman/util" import {deriveItems, getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store" import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Stores} from "./stores.js" /** @@ -40,7 +40,7 @@ export class MapPlugin { one: (key?: string, ...args: any[]) => Readable> subs: ((key: string, value: Maybe) => void)[] = [] - constructor(protected readonly ctx: IClient) { + constructor(protected readonly app: IApp) { this.index = projection(this.store) this.all = projection(deriveItems(this.store)) this.one = makeDeriveItem(this.store) @@ -109,8 +109,8 @@ export abstract class LoadableMapPlugin extends MapPlugin { abstract fetch(key: string, ...args: any[]): Promise - constructor(ctx: IClient, options: MakeLoadItemOptions = {}) { - super(ctx) + constructor(app: IApp, options: MakeLoadItemOptions = {}) { + super(app) // Subclasses implement `fetch` as an arrow field, whose initializer runs // *after* super() — so `this.fetch` is undefined here. makeLoadItem captures @@ -134,7 +134,7 @@ export type DerivedPluginOptions = { /** * Base class for a reactive, keyed collection of data derived from nostr events. * The repository is the single source of truth — the collection is a live view - * over `ctx.itemsByKey`, never a duplicated map. Subclasses implement `fetch` + * over `app.itemsByKey`, never a duplicated map. Subclasses implement `fetch` * (how to load an item by key from the network) and pass the filters/decoder via * `super`. * @@ -151,10 +151,10 @@ export abstract class DerivedPlugin { abstract fetch(key: string, ...args: any[]): Promise constructor( - protected readonly ctx: IClient, + protected readonly app: IApp, options: DerivedPluginOptions, ) { - const index = ctx.use(Stores).itemsByKey({ + const index = app.use(Stores).itemsByKey({ filters: options.filters, eventToItem: options.eventToItem, getKey: options.getKey, diff --git a/packages/client/src/plugins/blockedRelayLists.ts b/packages/app/src/plugins/blockedRelayLists.ts similarity index 79% rename from packages/client/src/plugins/blockedRelayLists.ts rename to packages/app/src/plugins/blockedRelayLists.ts index d054845..1b6e1b3 100644 --- a/packages/client/src/plugins/blockedRelayLists.ts +++ b/packages/app/src/plugins/blockedRelayLists.ts @@ -15,7 +15,7 @@ import {Network} from "./network.js" import {Router} from "./router.js" import {User} from "../user.js" import {Thunks} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model, @@ -23,8 +23,8 @@ import type {IClient} from "../client.js" * blocked relays are never selected. */ export class BlockedRelayLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [BLOCKED_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: list => list.event.pubkey, @@ -32,31 +32,31 @@ export class BlockedRelayLists extends DerivedPlugin } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints) } urls = (pubkey: string): Projection => this.project(pubkey, list => getRelaysFromList(list)) addRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS}) const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } removeRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: BLOCKED_RELAYS}) const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } setRelays = (urls: string[]) => - this.ctx.use(Thunks).publish({ + this.app.use(Thunks).publish({ event: makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}), - relays: this.ctx.use(Router).FromUser().getUrls(), + relays: this.app.use(Router).FromUser().getUrls(), }) } diff --git a/packages/client/src/plugins/blossom.ts b/packages/app/src/plugins/blossom.ts similarity index 82% rename from packages/client/src/plugins/blossom.ts rename to packages/app/src/plugins/blossom.ts index a9a9710..7469b16 100644 --- a/packages/client/src/plugins/blossom.ts +++ b/packages/app/src/plugins/blossom.ts @@ -2,15 +2,15 @@ import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {DerivedPlugin} from "./base.js" import {Network} from "./network.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox * model (the author's write relays), so it depends on the relay-list collection. */ export class BlossomServerLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [BLOSSOM_SERVERS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: list => list.event.pubkey, @@ -18,6 +18,6 @@ export class BlossomServerLists extends DerivedPlugin> & {feed: Feed} /** - * Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is - * delegated to `Wot`, and feeds fetch through THIS client's net context (pool + + * Builds `FeedController`s wired to this app. Scope/WOT pubkey resolution is + * delegated to `Wot`, and feeds fetch through THIS app's net context (pool + * repository) rather than the global one. */ export class Feeds { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} getPubkeysForScope = (scope: Scope): string[] => { - const $pubkey = this.ctx.user?.pubkey + const $pubkey = this.app.user?.pubkey if (!$pubkey) { return [] @@ -26,11 +26,11 @@ export class Feeds { case Scope.Self: return [$pubkey] case Scope.Follows: - return this.ctx.use(Wot).follows($pubkey).get() + return this.app.use(Wot).follows($pubkey).get() case Scope.Network: - return this.ctx.use(Wot).network($pubkey).get() + return this.app.use(Wot).network($pubkey).get() case Scope.Followers: - return this.ctx.use(Wot).followers($pubkey).get() + return this.app.use(Wot).followers($pubkey).get() default: return [] } @@ -38,11 +38,11 @@ export class Feeds { getPubkeysForWOTRange = (min: number, max: number): string[] => { const pubkeys = [] - const $maxWot = this.ctx.use(Wot).max.get() ?? 0 + const $maxWot = this.app.use(Wot).max.get() ?? 0 const thresholdMin = $maxWot * min const thresholdMax = $maxWot * max - for (const [tpk, score] of this.ctx.use(Wot).graph.get().entries()) { + for (const [tpk, score] of this.app.use(Wot).graph.get().entries()) { if (score >= thresholdMin && score <= thresholdMax) { pubkeys.push(tpk) } @@ -51,18 +51,18 @@ export class Feeds { return pubkeys } - // The net seam: route feed requests through this client's pool/repository so - // feeds fetch through THIS client rather than the global net context. + // The net seam: route feed requests through this app's pool/repository so + // feeds fetch through THIS app rather than the global net context. get netContext(): AdapterContext { - return {pool: this.ctx.pool, repository: this.ctx.repository} + return {pool: this.app.pool, repository: this.app.repository} } makeFeedController = (options: MakeFeedControllerOptions) => new FeedController({ - router: this.ctx.use(Router), + router: this.app.use(Router), getPubkeysForScope: this.getPubkeysForScope, getPubkeysForWOTRange: this.getPubkeysForWOTRange, - signer: this.ctx.user?.signer, + signer: this.app.user?.signer, context: this.netContext, ...options, }) diff --git a/packages/client/src/plugins/follows.ts b/packages/app/src/plugins/follows.ts similarity index 78% rename from packages/client/src/plugins/follows.ts rename to packages/app/src/plugins/follows.ts index 60be6a1..6d89951 100644 --- a/packages/client/src/plugins/follows.ts +++ b/packages/app/src/plugins/follows.ts @@ -11,15 +11,15 @@ import {DerivedPlugin} from "./base.js" import {Network} from "./network.js" import {Thunks} from "./thunk.js" import {User} from "../user.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the * author's write relays), so it depends on the relay-list collection. */ export class FollowLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [FOLLOWS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: followList => followList.event.pubkey, @@ -27,22 +27,22 @@ export class FollowLists extends DerivedPlugin> { } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints) } follow = async (tag: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS}) const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } unfollow = async (value: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: FOLLOWS}) const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } } diff --git a/packages/client/src/plugins/handles.ts b/packages/app/src/plugins/handles.ts similarity index 86% rename from packages/client/src/plugins/handles.ts rename to packages/app/src/plugins/handles.ts index 5f0c7ea..35dcef4 100644 --- a/packages/client/src/plugins/handles.ts +++ b/packages/app/src/plugins/handles.ts @@ -5,7 +5,7 @@ import type {Handle} from "@welshman/util" import {deriveDeduplicated} from "@welshman/store" import {LoadableMapPlugin, projection} from "./base.js" import type {Projection} from "./base.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Profiles} from "./profiles.js" /** @@ -16,18 +16,18 @@ import {Profiles} from "./profiles.js" * handle. */ export class Handles extends LoadableMapPlugin { - constructor(ctx: IClient) { - super(ctx) + constructor(app: IApp) { + super(app) } fetch = batcher(800, async (nip05s: string[]) => { const result = new Map() // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly - if (this.ctx.config.dufflepudUrl) { + if (this.app.config.dufflepudUrl) { const res: any = await tryCatch( async () => - await postJson(`${this.ctx.config.dufflepudUrl}/handle/info`, {handles: nip05s}), + await postJson(`${this.app.config.dufflepudUrl}/handle/info`, {handles: nip05s}), ) for (const {handle: nip05, info} of res?.data || []) { @@ -58,7 +58,7 @@ export class Handles extends LoadableMapPlugin { }) loadForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await this.ctx.use(Profiles).load(pubkey, relays) + const $profile = await this.app.use(Profiles).load(pubkey, relays) return $profile?.nip05 ? this.load($profile.nip05) : undefined } @@ -77,8 +77,8 @@ export class Handles extends LoadableMapPlugin { } return projection( - deriveDeduplicated([this.index.$, this.ctx.use(Profiles).one(pubkey, relays)], read), - () => read([this.index.get(), this.ctx.use(Profiles).get(pubkey)]), + deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read), + () => read([this.index.get(), this.app.use(Profiles).get(pubkey)]), ) } diff --git a/packages/client/src/plugins/messagingRelayLists.ts b/packages/app/src/plugins/messagingRelayLists.ts similarity index 79% rename from packages/client/src/plugins/messagingRelayLists.ts rename to packages/app/src/plugins/messagingRelayLists.ts index 11c06f5..0bb45fd 100644 --- a/packages/client/src/plugins/messagingRelayLists.ts +++ b/packages/app/src/plugins/messagingRelayLists.ts @@ -15,7 +15,7 @@ import {Network} from "./network.js" import {Router} from "./router.js" import {User} from "../user.js" import {Thunks} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the @@ -23,8 +23,8 @@ import type {IClient} from "../client.js" * collection. */ export class MessagingRelayLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [MESSAGING_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: list => list.event.pubkey, @@ -32,31 +32,31 @@ export class MessagingRelayLists extends DerivedPlugin => this.project(pubkey, list => getRelaysFromList(list)) addRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS}) const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } removeRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MESSAGING_RELAYS}) const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } setRelays = (urls: string[]) => - this.ctx.use(Thunks).publish({ + this.app.use(Thunks).publish({ event: makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}), - relays: this.ctx.use(Router).FromUser().getUrls(), + relays: this.app.use(Router).FromUser().getUrls(), }) } diff --git a/packages/client/src/plugins/mutes.ts b/packages/app/src/plugins/mutes.ts similarity index 75% rename from packages/client/src/plugins/mutes.ts rename to packages/app/src/plugins/mutes.ts index 2dc54b0..be44c9f 100644 --- a/packages/client/src/plugins/mutes.ts +++ b/packages/app/src/plugins/mutes.ts @@ -10,7 +10,7 @@ import { } from "@welshman/util" import type {TrustedEvent, PublishedList} from "@welshman/util" import {DerivedPlugin} from "./base.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Network} from "./network.js" import {Thunks} from "./thunk.js" import {Plaintext} from "./plaintext.js" @@ -21,11 +21,11 @@ import {User} from "../user.js" * encrypted content, so decoding goes through the plaintext cache. */ export class MuteLists extends DerivedPlugin { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [MUTES]}], eventToItem: async (event: TrustedEvent) => { - const content = await ctx.use(Plaintext).ensure(event) + const content = await app.use(Plaintext).ensure(event) return readList(asDecryptedEvent(event, {content})) }, @@ -34,38 +34,38 @@ export class MuteLists extends DerivedPlugin { } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints) } mutePublicly = async (tag: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES}) const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } mutePrivately = async (tag: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES}) const event = await addToListPrivately(list, tag).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } unmute = async (value: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES}) const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } setMutes = async (updates: {publicTags?: string[][]; privateTags?: string[][]}) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: MUTES}) const event = await updateList(list, updates).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } } diff --git a/packages/client/src/plugins/network.ts b/packages/app/src/plugins/network.ts similarity index 71% rename from packages/client/src/plugins/network.ts rename to packages/app/src/plugins/network.ts index 6eeec30..9b1f603 100644 --- a/packages/client/src/plugins/network.ts +++ b/packages/app/src/plugins/network.ts @@ -14,38 +14,38 @@ import type { import {addMinimalFallbacks} from "@welshman/router" import {Router} from "./router.js" import {RelayLists} from "./relayLists.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** - * Net utilities bound to the client's net context (its pool + repository). Reach - * it via `client.use(Network)`; `load` is a shared, batched loader. + * Net utilities bound to the app's net context (its pool + repository). Reach + * it via `app.use(Network)`; `load` is a shared, batched loader. */ export class Network { load: Loader - constructor(readonly ctx: IClient) { + constructor(readonly app: IApp) { this.load = this.makeLoader({delay: 50, timeout: 3000, threshold: 0.5}) } makeLoader = (options: Omit): Loader => - makeLoader({...options, context: this.ctx.netContext}) + makeLoader({...options, context: this.app.netContext}) request = (options: Omit) => - request({...options, context: this.ctx.netContext}) + request({...options, context: this.app.netContext}) publish = (options: Omit) => - publish({...options, context: this.ctx.netContext}) + publish({...options, context: this.app.netContext}) - diff = (options: Omit) => diff({...options, context: this.ctx.netContext}) + diff = (options: Omit) => diff({...options, context: this.app.netContext}) - pull = (options: Omit) => pull({...options, context: this.ctx.netContext}) + pull = (options: Omit) => pull({...options, context: this.app.netContext}) - push = (options: Omit) => push({...options, context: this.ctx.netContext}) + push = (options: Omit) => push({...options, context: this.app.netContext}) loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => { const filters: Filter[] = [{...filter, authors: [pubkey]}] - const writeRelays = getRelaysFromList(await this.ctx.use(RelayLists).load(pubkey), RelayMode.Write) - const allRelays = this.ctx + const writeRelays = getRelaysFromList(await this.app.use(RelayLists).load(pubkey), RelayMode.Write) + const allRelays = this.app .use(Router) .FromRelays([...relayHints, ...writeRelays]) .policy(addMinimalFallbacks) diff --git a/packages/client/src/plugins/pins.ts b/packages/app/src/plugins/pins.ts similarity index 77% rename from packages/client/src/plugins/pins.ts rename to packages/app/src/plugins/pins.ts index b2234d0..2c2d2ce 100644 --- a/packages/client/src/plugins/pins.ts +++ b/packages/app/src/plugins/pins.ts @@ -11,15 +11,15 @@ import {DerivedPlugin} from "./base.js" import {Network} from "./network.js" import {Thunks} from "./thunk.js" import {User} from "../user.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model * (the author's write relays), so it depends on the relay-list collection. */ export class PinLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [PINS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: pins => pins.event.pubkey, @@ -27,22 +27,22 @@ export class PinLists extends DerivedPlugin> { } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints) } pin = async (tag: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS}) const event = await addToListPublicly(list, tag).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } unpin = async (value: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: PINS}) const event = await removeFromList(list, value).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } } diff --git a/packages/client/src/plugins/plaintext.ts b/packages/app/src/plugins/plaintext.ts similarity index 84% rename from packages/client/src/plugins/plaintext.ts rename to packages/app/src/plugins/plaintext.ts index e984d21..5c992ab 100644 --- a/packages/client/src/plugins/plaintext.ts +++ b/packages/app/src/plugins/plaintext.ts @@ -8,12 +8,12 @@ import {MapPlugin} from "./base.js" */ export class Plaintext extends MapPlugin { ensure = async (event: TrustedEvent): Promise> => { - if (this.ctx.user?.pubkey !== event.pubkey) return + if (this.app.user?.pubkey !== event.pubkey) return let result = this.get(event.id) if (event.content && result === undefined) { try { - result = await decrypt(this.ctx.user.signer, event.pubkey, event.content) + result = await decrypt(this.app.user.signer, event.pubkey, event.content) this.set(event.id, result) } catch (e: any) { if (!String(e).match(/invalid base64/)) { diff --git a/packages/client/src/plugins/profiles.ts b/packages/app/src/plugins/profiles.ts similarity index 85% rename from packages/client/src/plugins/profiles.ts rename to packages/app/src/plugins/profiles.ts index 8bc5bd3..172e775 100644 --- a/packages/client/src/plugins/profiles.ts +++ b/packages/app/src/plugins/profiles.ts @@ -15,15 +15,15 @@ import type {Projection} from "./base.js" import {Network} from "./network.js" import {Router} from "./router.js" import {Thunks} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's * write relays), resolved through the relay-list collection at fetch time. */ export class Profiles extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [PROFILE]}], eventToItem: readProfile, getKey: profile => profile.event.pubkey, @@ -31,15 +31,15 @@ export class Profiles extends DerivedPlugin> { } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints) } publish = (profile: Profile) => { - const router = this.ctx.use(Router) + const router = this.app.use(Router) const relays = router.merge([router.Index(), router.FromUser()]).getUrls() const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) - return this.ctx.use(Thunks).publish({event, relays}) + return this.app.use(Thunks).publish({event, relays}) } display = (pubkey: string | undefined, ...args: any[]): Projection => { diff --git a/packages/client/src/plugins/relayLists.ts b/packages/app/src/plugins/relayLists.ts similarity index 84% rename from packages/client/src/plugins/relayLists.ts rename to packages/app/src/plugins/relayLists.ts index 8ad18fa..cb3e456 100644 --- a/packages/client/src/plugins/relayLists.ts +++ b/packages/app/src/plugins/relayLists.ts @@ -19,15 +19,15 @@ import {Router} from "./router.js" import {Network} from "./network.js" import {User} from "../user.js" import {Thunks} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other * outbox-model load depends on (see `Network.loadUsingOutbox`). */ export class RelayLists extends DerivedPlugin { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: (list: PublishedList) => list.event.pubkey, @@ -36,8 +36,8 @@ export class RelayLists extends DerivedPlugin { fetch(pubkey: string, relayHints: string[] = []) { const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}] - const networking = this.ctx.use(Network) - const router = this.ctx.use(Router) + const networking = this.app.use(Network) + const router = this.app.use(Router) return Promise.all([ networking.load({filters, relays: router.FromRelays(relayHints).getUrls()}), @@ -55,21 +55,21 @@ export class RelayLists extends DerivedPlugin { writeUrls = (pubkey: string): Projection => this.project(pubkey, list => getRelaysFromList(list, RelayMode.Write)) - // NIP-65 relay-list mutations for the client's user + // NIP-65 relay-list mutations for the app's user addRelay = async (url: string, mode: RelayMode) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS}) const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode]) const tags = [...list.publicTags.filter(nthNe(1, url)), tag] const event = {kind: list.kind, content: list.event?.content || "", tags} - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } removeRelay = async (url: string, mode: RelayMode) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS}) const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read @@ -84,23 +84,23 @@ export class RelayLists extends DerivedPlugin { // publishToOutbox is outbox-only, so build relays here to also notify the // removed relay of its removal - const relays = [url, ...this.ctx.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()] + const relays = [url, ...this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()] - return this.ctx.use(Thunks).publish({event, relays}) + return this.app.use(Thunks).publish({event, relays}) } setRelays = (tags: string[][]) => { - const router = this.ctx.use(Router) + const router = this.app.use(Router) const event = makeEvent(RELAYS, {tags}) const relays = router .merge([router.Index(), router.FromRelays(getRelayTagValues(tags))]) .getUrls() - return this.ctx.use(Thunks).publish({event, relays}) + return this.app.use(Thunks).publish({event, relays}) } setReadRelays = async (urls: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS}) const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1)) const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write]) @@ -108,11 +108,11 @@ export class RelayLists extends DerivedPlugin { const tags = [...writeTags, ...readTags] const event = {kind: list.kind, content: list.event?.content || "", tags} - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } setWriteRelays = async (urls: string[]) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: RELAYS}) const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1)) const readTags = readRelays.map(url => ["r", url, RelayMode.Read]) @@ -120,6 +120,6 @@ export class RelayLists extends DerivedPlugin { const tags = [...readTags, ...writeTags] const event = {kind: list.kind, content: list.event?.content || "", tags} - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } } diff --git a/packages/client/src/plugins/relayManagement.ts b/packages/app/src/plugins/relayManagement.ts similarity index 69% rename from packages/client/src/plugins/relayManagement.ts rename to packages/app/src/plugins/relayManagement.ts index e639618..3b55283 100644 --- a/packages/client/src/plugins/relayManagement.ts +++ b/packages/app/src/plugins/relayManagement.ts @@ -1,20 +1,20 @@ import {makeHttpAuth, sendManagementRequest} from "@welshman/util" import type {ManagementRequest} from "@welshman/util" import {User} from "../user.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** - * NIP-86 relay management. Signs an HTTP-auth event as the client's user and + * NIP-86 relay management. Signs an HTTP-auth event as the app's user and * sends an admin request to a relay's management endpoint. */ export class RelayManagement { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} post = async (url: string, request: ManagementRequest) => { url = url.replace(/^ws/, "http") const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request)) - const authEvent = await User.require(this.ctx).sign(authTemplate) + const authEvent = await User.require(this.app).sign(authTemplate) return sendManagementRequest(url, request, authEvent) } diff --git a/packages/client/src/plugins/relayStats.ts b/packages/app/src/plugins/relayStats.ts similarity index 96% rename from packages/client/src/plugins/relayStats.ts rename to packages/app/src/plugins/relayStats.ts index 5fa22ed..018b857 100644 --- a/packages/client/src/plugins/relayStats.ts +++ b/packages/app/src/plugins/relayStats.ts @@ -54,7 +54,7 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({ /** * Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic * the router uses to rank relays. A pure store — the socket wiring that fills it - * lives in `clientPolicyRelayStats`. + * lives in `appPolicyRelayStats`. */ export class RelayStats extends MapPlugin { getQuality = (url: string) => { @@ -62,9 +62,9 @@ export class RelayStats extends MapPlugin { if (!isRelayUrl(url)) return 0 // Skip relays the user has blocked - const pubkey = this.ctx.user?.pubkey + const pubkey = this.app.user?.pubkey - if (pubkey && this.ctx.use(BlockedRelayLists).urls(pubkey).get().includes(url)) { + if (pubkey && this.app.use(BlockedRelayLists).urls(pubkey).get().includes(url)) { return 0 } @@ -78,7 +78,7 @@ export class RelayStats extends MapPlugin { } // Prefer stuff we're connected to - if (this.ctx.pool.has(url)) return 1 + if (this.app.pool.has(url)) return 1 // Prefer stuff we've connected to in the past if (stats) return 0.9 diff --git a/packages/client/src/plugins/relays.ts b/packages/app/src/plugins/relays.ts similarity index 100% rename from packages/client/src/plugins/relays.ts rename to packages/app/src/plugins/relays.ts diff --git a/packages/client/src/plugins/rooms.ts b/packages/app/src/plugins/rooms.ts similarity index 86% rename from packages/client/src/plugins/rooms.ts rename to packages/app/src/plugins/rooms.ts index 4a542c2..5c47279 100644 --- a/packages/client/src/plugins/rooms.ts +++ b/packages/app/src/plugins/rooms.ts @@ -10,17 +10,17 @@ import { import type {RoomMeta} from "@welshman/util" import {Thunks} from "./thunk.js" import type {ThunkOptions} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * NIP-29 relay-based group (room) management. Each method publishes the relevant - * room event to the given relay as the client's user. + * room event to the given relay as the app's user. */ export class Rooms { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} private publish = (url: string, event: ThunkOptions["event"]) => - this.ctx.use(Thunks).publish({event, relays: [url]}) + this.app.use(Thunks).publish({event, relays: [url]}) create = (url: string, room: RoomMeta) => this.publish(url, makeRoomCreateEvent(room)) diff --git a/packages/app/src/plugins/router.ts b/packages/app/src/plugins/router.ts new file mode 100644 index 0000000..aa0a6cc --- /dev/null +++ b/packages/app/src/plugins/router.ts @@ -0,0 +1,29 @@ +import {RelayMode} from "@welshman/util" +import {Router as BaseRouter} from "@welshman/router" +import {RelayLists} from "./relayLists.js" +import {RelayStats} from "./relayStats.js" +import type {IApp} from "../app.js" + +/** + * The upstream `@welshman/router` Router, wired to this app: relay lists come + * from the `RelayLists` collection, quality from `RelayStats`, and the user + * pubkey + relay-getters from the app (via `app.config`). Reach it via + * `app.use(Router)`. This replaces the old forked copy — one source of truth, + * no global `routerContext`/`Router.get()`. + */ +export class Router extends BaseRouter { + constructor(app: IApp) { + super({ + getUserPubkey: () => app.user?.pubkey, + getPubkeyRelays: (pubkey, mode) => + (mode === RelayMode.Read + ? app.use(RelayLists).readUrls(pubkey) + : app.use(RelayLists).writeUrls(pubkey) + ).get(), + getRelayQuality: url => app.use(RelayStats).getQuality(url), + getDefaultRelays: app.config.getDefaultRelays, + getIndexerRelays: app.config.getIndexerRelays, + getSearchRelays: app.config.getSearchRelays, + }) + } +} diff --git a/packages/client/src/plugins/search.ts b/packages/app/src/plugins/search.ts similarity index 83% rename from packages/client/src/plugins/search.ts rename to packages/app/src/plugins/search.ts index 3bad04e..5f4b4f0 100644 --- a/packages/client/src/plugins/search.ts +++ b/packages/app/src/plugins/search.ts @@ -7,7 +7,7 @@ import {dec, inc, sortBy} from "@welshman/lib" import {PROFILE} from "@welshman/util" import type {PublishedProfile, RelayProfile} from "@welshman/util" import {throttled} from "@welshman/store" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Network} from "./network.js" import {Router} from "./router.js" import {Profiles} from "./profiles.js" @@ -58,18 +58,18 @@ export const createSearch = (options: T[], opts: SearchOptions): Sea } /** - * Reactive fuzzy searches over the client's profiles, topics, and relays. + * Reactive fuzzy searches over the app's profiles, topics, and relays. * `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and - * fires a debounced NIP-50 network search through the client's loader. + * fires a debounced NIP-50 network search through the app's loader. */ export class Searches { profileSearch: Readable> topicSearch: Readable> relaySearch: Readable> - constructor(readonly ctx: IClient) { + constructor(readonly app: IApp) { this.profileSearch = derived( - [throttled(800, this.ctx.use(Profiles).all.$), throttled(800, this.ctx.use(Handles).index.$)], + [throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)], ([$profiles, $handlesByNip05]) => { // Remove invalid nip05's from profiles const options = $profiles.map(p => { @@ -82,9 +82,9 @@ export class Searches { onSearch: this.searchProfiles, getValue: (profile: PublishedProfile) => profile.event.pubkey, sortFn: ({score = 1, item}) => { - const wotScore = this.ctx.use(Wot).graph.get().get(item.event.pubkey) || 0 + const wotScore = this.app.use(Wot).graph.get().get(item.event.pubkey) || 0 - return dec(score) * inc(wotScore / (this.ctx.use(Wot).max.get() || 1)) + return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1)) }, fuseOptions: { keys: [ @@ -100,14 +100,14 @@ export class Searches { }, ) - this.topicSearch = derived(this.ctx.use(Topics).all, $topics => + this.topicSearch = derived(this.app.use(Topics).all, $topics => createSearch($topics, { getValue: (topic: Topic) => topic.name, fuseOptions: {keys: ["name"]}, }), ) - this.relaySearch = derived(this.ctx.use(Relays).all.$, $relays => + this.relaySearch = derived(this.app.use(Relays).all.$, $relays => createSearch($relays, { getValue: (relay: RelayProfile) => relay.url, fuseOptions: { @@ -119,9 +119,9 @@ export class Searches { searchProfiles = debounce(500, (search: string) => { if (search.length > 2) { - this.ctx.use(Network).load({ + this.app.use(Network).load({ filters: [{kinds: [PROFILE], search}], - relays: this.ctx.use(Router).Search().getUrls(), + relays: this.app.use(Router).Search().getUrls(), }) } }) diff --git a/packages/client/src/plugins/searchRelayLists.ts b/packages/app/src/plugins/searchRelayLists.ts similarity index 79% rename from packages/client/src/plugins/searchRelayLists.ts rename to packages/app/src/plugins/searchRelayLists.ts index feca237..e5c8b08 100644 --- a/packages/client/src/plugins/searchRelayLists.ts +++ b/packages/app/src/plugins/searchRelayLists.ts @@ -15,7 +15,7 @@ import {Network} from "./network.js" import {Router} from "./router.js" import {User} from "../user.js" import {Thunks} from "./thunk.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the @@ -23,8 +23,8 @@ import type {IClient} from "../client.js" * collection. */ export class SearchRelayLists extends DerivedPlugin> { - constructor(ctx: IClient) { - super(ctx, { + constructor(app: IApp) { + super(app, { filters: [{kinds: [SEARCH_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), getKey: searchRelayList => searchRelayList.event.pubkey, @@ -32,31 +32,31 @@ export class SearchRelayLists extends DerivedPlugin> } fetch(pubkey: string, relayHints: string[] = []) { - return this.ctx.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints) + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints) } urls = (pubkey: string): Projection => this.project(pubkey, list => getRelaysFromList(list)) addRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS}) const event = await addToListPublicly(list, ["relay", url]).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } removeRelay = async (url: string) => { - const user = User.require(this.ctx) + const user = User.require(this.app) const list = (await this.forceLoad(user.pubkey)) || makeList({kind: SEARCH_RELAYS}) const event = await removeFromList(list, url).reconcile(user.nip44EncryptToSelf) - return this.ctx.use(Thunks).publishToOutbox({event}) + return this.app.use(Thunks).publishToOutbox({event}) } setRelays = (urls: string[]) => - this.ctx.use(Thunks).publish({ + this.app.use(Thunks).publish({ event: makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}), - relays: this.ctx.use(Router).FromUser().getUrls(), + relays: this.app.use(Router).FromUser().getUrls(), }) } diff --git a/packages/client/src/plugins/stores.ts b/packages/app/src/plugins/stores.ts similarity index 55% rename from packages/client/src/plugins/stores.ts rename to packages/app/src/plugins/stores.ts index 7d861f7..6be5215 100644 --- a/packages/client/src/plugins/stores.ts +++ b/packages/app/src/plugins/stores.ts @@ -18,41 +18,41 @@ import type { ItemsByKeyOptions, } from "@welshman/store" import type {TrustedEvent} from "@welshman/util" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** - * Store/derivation utilities bound to the client's repository and tracker. Reach - * it via `client.use(Stores)`. + * Store/derivation utilities bound to the app's repository and tracker. Reach + * it via `app.use(Stores)`. */ export class Stores { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} getEventsById = (options: Omit) => - getEventsById({...options, repository: this.ctx.repository}) + getEventsById({...options, repository: this.app.repository}) eventsById = (options: Omit) => - deriveEventsById({...options, repository: this.ctx.repository}) + deriveEventsById({...options, repository: this.app.repository}) events = (options: Omit) => - deriveEvents({...options, repository: this.ctx.repository}) + deriveEvents({...options, repository: this.app.repository}) makeEvent = (options: Omit) => - makeDeriveEvent({...options, repository: this.ctx.repository}) + makeDeriveEvent({...options, repository: this.app.repository}) getEventsByIdByUrl = (options: Omit) => - getEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + getEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository}) eventsByIdByUrl = (options: Omit) => - deriveEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + deriveEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository}) getEventsByIdForUrl = (options: Omit) => - getEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + getEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository}) eventsByIdForUrl = (options: Omit) => - deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + deriveEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository}) itemsByKey = (options: Omit, "repository">) => - deriveItemsByKey({...options, repository: this.ctx.repository}) + deriveItemsByKey({...options, repository: this.app.repository}) - isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event) + isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.app.repository, event) } diff --git a/packages/client/src/plugins/sync.ts b/packages/app/src/plugins/sync.ts similarity index 79% rename from packages/client/src/plugins/sync.ts rename to packages/app/src/plugins/sync.ts index 4edb5dd..a6b787d 100644 --- a/packages/client/src/plugins/sync.ts +++ b/packages/app/src/plugins/sync.ts @@ -1,6 +1,6 @@ import {isSignedEvent} from "@welshman/util" import type {Filter, SignedEvent} from "@welshman/util" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Network} from "./network.js" import {Relays} from "./relays.js" @@ -16,18 +16,18 @@ export type AppSyncOpts = { * from the `Relays` collection to detect negentropy support. */ export class Sync { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} query = (filters: Filter[]) => - this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) + this.app.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) pull = async ({relays, filters}: AppSyncOpts) => { - const net = this.ctx.use(Network) + const net = this.app.use(Network) const events = this.query(filters).filter(isSignedEvent) await Promise.all( relays.map(async relay => { - if (await this.ctx.use(Relays).hasNegentropy(relay)) { + if (await this.app.use(Relays).hasNegentropy(relay)) { await net.pull({filters, events, relays: [relay]}) } else { await net.request({filters, relays: [relay], autoClose: true}) @@ -37,12 +37,12 @@ export class Sync { } push = async ({relays, filters}: AppSyncOpts) => { - const net = this.ctx.use(Network) + const net = this.app.use(Network) const events = this.query(filters).filter(isSignedEvent) await Promise.all( relays.map(async relay => { - if (await this.ctx.use(Relays).hasNegentropy(relay)) { + if (await this.app.use(Relays).hasNegentropy(relay)) { await net.push({filters, events, relays: [relay]}) } else { await Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]}))) diff --git a/packages/client/src/plugins/tags.ts b/packages/app/src/plugins/tags.ts similarity index 80% rename from packages/client/src/plugins/tags.ts rename to packages/app/src/plugins/tags.ts index 81005d9..bc47231 100644 --- a/packages/client/src/plugins/tags.ts +++ b/packages/app/src/plugins/tags.ts @@ -10,33 +10,33 @@ import { import type {TrustedEvent} from "@welshman/util" import {Router} from "./router.js" import {Profiles} from "./profiles.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" /** * Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router - * for relay hints, the profiles collection for display names, and the client's + * for relay hints, the profiles collection for display names, and the app's * user to avoid self-tagging. */ export class Tags { - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} tagZapSplit = (pubkey: string, split = 1) => [ "zap", pubkey, - this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "", + this.app.use(Router).FromPubkey(pubkey).getUrl() || "", String(split), ] tagPubkey = (pubkey: string) => [ "p", pubkey, - this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "", - this.ctx.use(Profiles).display(pubkey).get(), + this.app.use(Router).FromPubkey(pubkey).getUrl() || "", + this.app.use(Profiles).display(pubkey).get(), ] tagEvent = (event: TrustedEvent, url = "", mark = "") => { if (!url) { - url = this.ctx.use(Router).Event(event).getUrl() || "" + url = this.app.use(Router).Event(event).getUrl() || "" } const tags = [["e", event.id, url, mark, event.pubkey]] @@ -50,11 +50,11 @@ export class Tags { tagEventPubkeys = (event: TrustedEvent) => uniq( - remove(this.ctx.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]), + remove(this.app.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]), ).map(pubkey => this.tagPubkey(pubkey)) tagEventForQuote = (event: TrustedEvent, relay?: string) => { - const hint = relay || this.ctx.use(Router).Event(event).getUrl() || "" + const hint = relay || this.app.use(Router).Event(event).getUrl() || "" return ["q", event.id, hint, event.pubkey] } @@ -64,13 +64,13 @@ export class Tags { const {roots, replies} = getReplyTags(event.tags) const parents = roots.length > 0 ? roots : replies const mark = parents.length > 0 ? "reply" : "root" - const hint = relay || this.ctx.use(Router).Event(event).getUrl() || "" + const hint = relay || this.app.use(Router).Event(event).getUrl() || "" // If the parent included roots use them, otherwise use replies as a fallback for (const [k, id, originalHint = "", _, pubkey = ""] of parents) { const hint = isShareableRelayUrl(originalHint) ? originalHint - : this.ctx.use(Router).EventRoots(event).getUrl() + : this.app.use(Router).EventRoots(event).getUrl() tags.push([k, id, hint || "", "root", pubkey]) } @@ -87,8 +87,8 @@ export class Tags { } tagEventForComment = (event: TrustedEvent, relay?: string) => { - const pubkeyHint = this.ctx.use(Router).FromPubkey(event.pubkey).getUrl() || "" - const eventHint = relay || this.ctx.use(Router).Event(event).getUrl() || "" + const pubkeyHint = this.app.use(Router).FromPubkey(event.pubkey).getUrl() || "" + const eventHint = relay || this.app.use(Router).Event(event).getUrl() || "" const address = getAddress(event) const seenRoots = new Set() const tags: string[][] = [] @@ -122,11 +122,11 @@ export class Tags { } tagEventForReaction = (event: TrustedEvent, relay?: string) => { - const hint = relay || this.ctx.use(Router).Event(event).getUrl() || "" + const hint = relay || this.app.use(Router).Event(event).getUrl() || "" const tags: string[][] = [] // Mention the event's author - if (event.pubkey !== this.ctx.user?.pubkey) { + if (event.pubkey !== this.app.user?.pubkey) { tags.push(this.tagPubkey(event.pubkey)) } diff --git a/packages/client/src/plugins/thunk.ts b/packages/app/src/plugins/thunk.ts similarity index 87% rename from packages/client/src/plugins/thunk.ts rename to packages/app/src/plugins/thunk.ts index 28bcde5..2fd34f3 100644 --- a/packages/client/src/plugins/thunk.ts +++ b/packages/app/src/plugins/thunk.ts @@ -13,7 +13,7 @@ import { } from "@welshman/util" import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net" import {Nip01Signer, Nip59} from "@welshman/signer" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Network} from "./network.js" import {addMinimalFallbacks} from "@welshman/router" import {Router} from "./router.js" @@ -22,7 +22,7 @@ import {User} from "../user.js" export type ThunkOptions = Override< PublishOptions, { - client: IClient + app: IApp event: EventTemplate recipient?: string delay?: number @@ -156,7 +156,7 @@ export class Thunk extends BaseThunk { } get user() { - return User.require(this.options.client) + return User.require(this.options.app) } _fail(detail: string) { @@ -201,7 +201,7 @@ export class Thunk extends BaseThunk { } // Send it off - await this.options.client.use(Network).publish({ + await this.options.app.use(Network).publish({ ...this.options, event, onSuccess: (result: PublishResult) => { @@ -219,7 +219,7 @@ export class Thunk extends BaseThunk { onAborted: this._setAborted, onComplete: (result: PublishResult) => { if (result.status !== PublishStatus.Success) { - this.options.client.tracker.removeRelay(event.id, result.relay) + this.options.app.tracker.removeRelay(event.id, result.relay) } this.options.onComplete?.(result) @@ -251,7 +251,7 @@ export class Thunk extends BaseThunk { }) } - this.options.client.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event}) + this.options.app.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event}) return this._publish(this.wrap) } @@ -279,13 +279,13 @@ export class Thunk extends BaseThunk { // Update tracker and repository with the signed event since the id will have changed if (this.options.pow) { for (const url of this.options.relays) { - this.options.client.tracker.removeRelay(this.event.id, url) - this.options.client.tracker.track(signedEvent.id, url) + this.options.app.tracker.removeRelay(this.event.id, url) + this.options.app.tracker.track(signedEvent.id, url) } } - this.options.client.repository.removeEvent(this.event.id) - this.options.client.repository.publish(signedEvent) + this.options.app.repository.removeEvent(this.event.id) + this.options.app.repository.publish(signedEvent) return this._publish(signedEvent) } catch (e: any) { @@ -335,9 +335,9 @@ export class MergedThunk extends BaseThunk { } /** - * Per-client thunk manager — the publish-side counterpart of `Network`. Owns - * the client's optimistic-publish `history` store and the `queue` that paces - * publishing. Reach it via `client.use(Thunks)`; `publish` fills in the client + * Per-app thunk manager — the publish-side counterpart of `Network`. Owns + * the app's optimistic-publish `history` store and the `queue` that paces + * publishing. Reach it via `app.use(Thunks)`; `publish` fills in the app * (the acting user is derived from it), enqueues the thunk (optimistically * writing it to the repository), and returns it. */ @@ -352,31 +352,31 @@ export class Thunks { }, }) - constructor(readonly ctx: IClient) {} + constructor(readonly app: IApp) {} enqueue(thunk: Thunk) { this.queue.push(thunk) for (const url of thunk.options.relays) { - this.ctx.tracker.track(thunk.event.id, url) + this.app.tracker.track(thunk.event.id, url) } - this.ctx.repository.publish(thunk.event) + this.app.repository.publish(thunk.event) this.history.update($history => append(thunk, $history)) thunk.controller.signal.addEventListener("abort", () => { if (thunk.wrap) { - this.ctx.wrapManager.remove(thunk.wrap.id) + this.app.wrapManager.remove(thunk.wrap.id) } else { - this.ctx.repository.removeEvent(thunk.event.id) + this.app.repository.removeEvent(thunk.event.id) } this.history.update($history => remove(thunk, $history)) }) } - publish = (options: Omit) => { - const thunk = new Thunk({...options, client: this.ctx}) + publish = (options: Omit) => { + const thunk = new Thunk({...options, app: this.app}) this.enqueue(thunk) @@ -384,10 +384,10 @@ export class Thunks { } // Publish as the user to their outbox (write) relays - publishToOutbox = (options: Omit) => + publishToOutbox = (options: Omit) => this.publish({ ...options, - relays: this.ctx.use(Router).FromUser().policy(addMinimalFallbacks).getUrls(), + relays: this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls(), }) retry = (thunk: BaseThunk) => diff --git a/packages/client/src/plugins/topics.ts b/packages/app/src/plugins/topics.ts similarity index 85% rename from packages/client/src/plugins/topics.ts rename to packages/app/src/plugins/topics.ts index bf79bb2..95e8b8f 100644 --- a/packages/client/src/plugins/topics.ts +++ b/packages/app/src/plugins/topics.ts @@ -4,7 +4,7 @@ import {on} from "@welshman/lib" import {getTopicTagValues} from "@welshman/util" import type {RepositoryUpdate} from "@welshman/net" import {deriveItems} from "@welshman/store" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" export type Topic = { name: string @@ -12,14 +12,14 @@ export type Topic = { } /** - * Hashtag topics with occurrence counts, derived live from the client's + * Hashtag topics with occurrence counts, derived live from the app's * repository tag index. */ export class Topics { byName: Readable> all: Readable - constructor(readonly ctx: IClient) { + constructor(readonly app: IApp) { const topicsByName = new Map() const addTopic = (name: string) => { @@ -32,14 +32,14 @@ export class Topics { } } - for (const tagString of ctx.repository.eventsByTag.keys()) { + for (const tagString of app.repository.eventsByTag.keys()) { if (tagString.startsWith("t:")) { addTopic(tagString.slice(2).toLowerCase()) } } this.byName = readable(topicsByName, set => - on(ctx.repository, "update", ({added}: RepositoryUpdate) => { + on(app.repository, "update", ({added}: RepositoryUpdate) => { let dirty = false for (const event of added) { diff --git a/packages/client/src/plugins/wot.ts b/packages/app/src/plugins/wot.ts similarity index 83% rename from packages/client/src/plugins/wot.ts rename to packages/app/src/plugins/wot.ts index 94f4f23..61a8aee 100644 --- a/packages/client/src/plugins/wot.ts +++ b/packages/app/src/plugins/wot.ts @@ -2,7 +2,7 @@ import {readable, derived} from "svelte/store" import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib" import {getListTags, getPubkeyTagValues} from "@welshman/util" import type {List} from "@welshman/util" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {projection, projectFrom} from "./base.js" import type {Projection} from "./base.js" import {FollowLists} from "./follows.js" @@ -12,7 +12,7 @@ const listPubkeys = (list: List | undefined) => getPubkeyTagValues(getListTags(l /** * Web-of-trust scoring derived from follow and mute lists. The trust graph is - * built from the perspective of the client's user (or, with no user, the union + * built from the perspective of the app's user (or, with no user, the union * of every known follow list) and updated reactively as lists change. * * The aggregate `*ByPubkey`/`graph`/`max` fields and the parameterized methods @@ -25,9 +25,9 @@ export class Wot { graph: Projection> max: Projection - constructor(readonly ctx: IClient) { + constructor(readonly app: IApp) { const followersByPubkeyStore = readable(new Map>(), set => - this.ctx.use(FollowLists).index.$.subscribe( + this.app.use(FollowLists).index.$.subscribe( throttle(1000, lists => { const $followersByPubkey = new Map>() @@ -43,7 +43,7 @@ export class Wot { ) const mutersByPubkeyStore = readable(new Map>(), set => - this.ctx.use(MuteLists).index.$.subscribe( + this.app.use(MuteLists).index.$.subscribe( throttle(1000, lists => { const $mutersByPubkey = new Map>() @@ -60,9 +60,9 @@ export class Wot { const graphStore = readable(new Map(), set => { const rebuild = throttle(1000, () => { - const $followLists = this.ctx.use(FollowLists).index.get() - const $muteLists = this.ctx.use(MuteLists).index.get() - const $pubkey = this.ctx.user?.pubkey + const $followLists = this.app.use(FollowLists).index.get() + const $muteLists = this.app.use(MuteLists).index.get() + const $pubkey = this.app.user?.pubkey const $graph = new Map() const roots = $pubkey ? listPubkeys($followLists.get($pubkey)) : Array.from($followLists.keys()) @@ -80,8 +80,8 @@ export class Wot { }) const unsubscribers = [ - this.ctx.use(FollowLists).index.$.subscribe(rebuild), - this.ctx.use(MuteLists).index.$.subscribe(rebuild), + this.app.use(FollowLists).index.$.subscribe(rebuild), + this.app.use(MuteLists).index.$.subscribe(rebuild), ] return () => unsubscribers.forEach(unsubscribe => unsubscribe()) @@ -96,13 +96,13 @@ export class Wot { } follows = (pubkey: string): Projection => - projectFrom(this.ctx.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey))) + projectFrom(this.app.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey))) mutes = (pubkey: string): Projection => - projectFrom(this.ctx.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey))) + projectFrom(this.app.use(MuteLists).index, $lists => listPubkeys($lists.get(pubkey))) network = (pubkey: string): Projection => - projectFrom(this.ctx.use(FollowLists).index, $lists => { + projectFrom(this.app.use(FollowLists).index, $lists => { const pubkeys = new Set(listPubkeys($lists.get(pubkey))) const network = new Set() @@ -124,7 +124,7 @@ export class Wot { projectFrom(this.mutersByPubkey, $muters => Array.from($muters.get(pubkey) || [])) followsWhoFollow = (pubkey: string, target: string): Projection => - projectFrom(this.ctx.use(FollowLists).index, $lists => + projectFrom(this.app.use(FollowLists).index, $lists => listPubkeys($lists.get(pubkey)).filter(other => listPubkeys($lists.get(other)).includes(target), ), @@ -138,10 +138,10 @@ export class Wot { return projection( derived( - [this.ctx.use(FollowLists).index.$, this.ctx.use(MuteLists).index.$], + [this.app.use(FollowLists).index.$, this.app.use(MuteLists).index.$], ([$follows, $mutes]) => read($follows, $mutes), ), - () => read(this.ctx.use(FollowLists).index.get(), this.ctx.use(MuteLists).index.get()), + () => read(this.app.use(FollowLists).index.get(), this.app.use(MuteLists).index.get()), ) } @@ -171,8 +171,8 @@ export class Wot { return projection( derived( [ - this.ctx.use(FollowLists).index.$, - this.ctx.use(MuteLists).index.$, + this.app.use(FollowLists).index.$, + this.app.use(MuteLists).index.$, this.followersByPubkey.$, this.mutersByPubkey.$, ], @@ -180,8 +180,8 @@ export class Wot { ), () => read( - this.ctx.use(FollowLists).index.get(), - this.ctx.use(MuteLists).index.get(), + this.app.use(FollowLists).index.get(), + this.app.use(MuteLists).index.get(), this.followersByPubkey.get(), this.mutersByPubkey.get(), ), diff --git a/packages/client/src/plugins/wraps.ts b/packages/app/src/plugins/wraps.ts similarity index 72% rename from packages/client/src/plugins/wraps.ts rename to packages/app/src/plugins/wraps.ts index b35e1cb..4ad13c1 100644 --- a/packages/client/src/plugins/wraps.ts +++ b/packages/app/src/plugins/wraps.ts @@ -7,34 +7,34 @@ import {MergedThunk, Thunks} from "./thunk.js" import type {ThunkOptions} from "./thunk.js" import {User} from "../user.js" import {MessagingRelayLists} from "./messagingRelayLists.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" export type SendWrappedOptions = Omit< ThunkOptions, - "event" | "relays" | "recipient" | "client" | "user" + "event" | "relays" | "recipient" | "app" | "user" > & { event: EventTemplate recipients: string[] } /** - * Per-client wrap (NIP-59) state: the unwrap queue plus failure/dedup - * tracking. Scoped to `ctx.user`, so a client only ever unwraps its own user's + * Per-app wrap (NIP-59) state: the unwrap queue plus failure/dedup + * tracking. Scoped to `app.user`, so an app only ever unwraps its own user's * messages into its own repository — which is what keeps DM history from being * merged across identities. The repository subscription that feeds it lives in - * `clientPolicyWraps`. + * `appPolicyWraps`. */ export class Wraps { failedUnwraps = new Set() queue: TaskQueue - constructor(readonly ctx: IClient) { + constructor(readonly app: IApp) { this.queue = new TaskQueue({ batchSize: 50, batchDelay: 30, processItem: async (wrap: TrustedEvent) => { - const signer = this.ctx.user?.signer - const recipient = this.ctx.user?.pubkey + const signer = this.app.user?.signer + const recipient = this.app.user?.pubkey // Only unwrap messages addressed to our user if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) { @@ -44,7 +44,7 @@ export class Wraps { try { const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent) - this.ctx.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient}) + this.app.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient}) } catch (e) { this.failedUnwraps.add(wrap.id) } @@ -54,15 +54,15 @@ export class Wraps { enqueue = (wrap: TrustedEvent) => { if (this.failedUnwraps.has(wrap.id)) return - if (this.ctx.wrapManager.getRumor(wrap.id)) return + if (this.app.wrapManager.getRumor(wrap.id)) return this.queue.push(wrap) } // NIP-59: wrap an event for each recipient (using their messaging relays) and - // publish the wraps as the client's user. + // publish the wraps as the app's user. publish = async ({event, recipients, ...options}: SendWrappedOptions) => { - const user = User.require(this.ctx) + const user = User.require(this.app) // Stabilize the event id across the different wraps const stableEvent = prep(event, user.pubkey, now()) @@ -70,9 +70,9 @@ export class Wraps { return new MergedThunk( await Promise.all( uniq(recipients).map(async recipient => { - const relays = getRelaysFromList(await this.ctx.use(MessagingRelayLists).load(recipient)) + const relays = getRelaysFromList(await this.app.use(MessagingRelayLists).load(recipient)) - return this.ctx.use(Thunks).publish({event: stableEvent, relays, recipient, ...options}) + return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options}) }), ), ) diff --git a/packages/client/src/plugins/zappers.ts b/packages/app/src/plugins/zappers.ts similarity index 92% rename from packages/client/src/plugins/zappers.ts rename to packages/app/src/plugins/zappers.ts index 1bd353f..52f5ce4 100644 --- a/packages/client/src/plugins/zappers.ts +++ b/packages/app/src/plugins/zappers.ts @@ -14,7 +14,7 @@ import type {Zapper, Zap, TrustedEvent} from "@welshman/util" import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store" import {LoadableMapPlugin, projection} from "./base.js" import type {Projection} from "./base.js" -import type {IClient} from "../client.js" +import type {IApp} from "../app.js" import {Profiles} from "./profiles.js" /** @@ -38,11 +38,11 @@ export class Zappers extends LoadableMapPlugin { } } - if (this.ctx.config.dufflepudUrl) { + if (this.app.config.dufflepudUrl) { const hexUrls = valid.map(bech32ToHex) const res: any = await tryCatch( async () => - await postJson(`${this.ctx.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}), + await postJson(`${this.app.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}), ) for (const {lnurl, info} of res?.data || []) { @@ -64,7 +64,7 @@ export class Zappers extends LoadableMapPlugin { }) loadForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await this.ctx.use(Profiles).load(pubkey, relays) + const $profile = await this.app.use(Profiles).load(pubkey, relays) return $profile?.lnurl ? this.load($profile.lnurl) : undefined } @@ -76,8 +76,8 @@ export class Zappers extends LoadableMapPlugin { $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined return projection( - deriveDeduplicated([this.index.$, this.ctx.use(Profiles).one(pubkey, relays)], read), - () => read([this.index.get(), this.ctx.use(Profiles).get(pubkey)]), + deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read), + () => read([this.index.get(), this.app.use(Profiles).get(pubkey)]), ) } @@ -111,7 +111,7 @@ export class Zappers extends LoadableMapPlugin { validZapReceipts = (zapReceipts: TrustedEvent[], parent: TrustedEvent): Projection => { const splits = getZapSplits(parent) - const profiles = this.ctx.use(Profiles) + const profiles = this.app.use(Profiles) // Ensure each recipient's profile (-> lnurl) and zapper are being loaded. for (const split of splits) { diff --git a/packages/client/src/policy.ts b/packages/app/src/policy.ts similarity index 52% rename from packages/client/src/policy.ts rename to packages/app/src/policy.ts index b8fc636..39b0c08 100644 --- a/packages/client/src/policy.ts +++ b/packages/app/src/policy.ts @@ -4,7 +4,7 @@ import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net" import type {RelayMessage, Socket} from "@welshman/net" -import type {IClient} from "./client.js" +import type {IApp} from "./app.js" import {RelayStats} from "./plugins/relayStats.js" import {Wraps} from "./plugins/wraps.js" import {BlockedRelayLists} from "./plugins/blockedRelayLists.js" @@ -12,70 +12,70 @@ import {LoggingSigner} from "./logging.js" import type {LogMessage} from "./logging.js" /** - * A client policy is a side effect applied once per client at construction, + * An app policy is a side effect applied once per app at construction, * returning a cleanup function — directly analogous to a socket policy. Policies * own everything that subscribes or links components together (event ingestion, * stats collection, gift-wrap unwrapping), so the data classes themselves stay * pure and free of subscriptions, and teardown is centralized in `cleanup()`. */ -export type ClientPolicy = (client: IClient) => Unsubscriber +export type AppPolicy = (app: IApp) => Unsubscriber /** - * Builds a client policy that authenticates the client's sockets (NIP-42) with + * Builds an app policy that authenticates the app's sockets (NIP-42) with * the user's signer. It appends an auth socket policy to the pool's * `socketPolicies`, so every socket the pool creates answers AUTH challenges * according to `shouldAuth`; the policy is spliced back out on cleanup. No-op - * when the client has no user. + * when the app has no user. * - * Use the `clientPolicyAuthAlways` / `clientPolicyAuthNever` presets below, or + * Use the `appPolicyAuthAlways` / `appPolicyAuthNever` presets below, or * call this with a custom predicate. */ -export const makeClientPolicyAuth = - (shouldAuth: (socket: Socket, client: IClient) => boolean): ClientPolicy => - client => { - if (!client.user) { +export const makeAppPolicyAuth = + (shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy => + app => { + if (!app.user) { return noop } const policy = makeSocketPolicyAuth({ - sign: client.user.signer.sign, - shouldAuth: socket => shouldAuth(socket, client), + sign: app.user.signer.sign, + shouldAuth: socket => shouldAuth(socket, app), }) - client.pool.socketPolicies.push(policy) + app.pool.socketPolicies.push(policy) return () => { - const index = client.pool.socketPolicies.indexOf(policy) + const index = app.pool.socketPolicies.indexOf(policy) if (index !== -1) { - client.pool.socketPolicies.splice(index, 1) + app.pool.socketPolicies.splice(index, 1) } } } -export const clientPolicyAuthNever = makeClientPolicyAuth(always(false)) +export const appPolicyAuthNever = makeAppPolicyAuth(always(false)) -export const clientPolicyAuthAlways = makeClientPolicyAuth(always(true)) +export const appPolicyAuthAlways = makeAppPolicyAuth(always(true)) -export const clientPolicyAuthUnlessBlocked = makeClientPolicyAuth((socket, client) => { - if (!client.user) { +export const appPolicyAuthUnlessBlocked = makeAppPolicyAuth((socket, app) => { + if (!app.user) { return false } - return !client + return !app .use(BlockedRelayLists) - .urls(client.user.pubkey) + .urls(app.user.pubkey) .get() .includes(socket.url) }) /** - * Ingests every event received on any socket into the client's repository. The + * Ingests every event received on any socket into the app's repository. The * net layer doesn't do this for us, and it's how all the repository-backed * collections (and gift-wrap unwrapping) get populated. */ -export const clientPolicyIngest: ClientPolicy = client => - client.pool.subscribe(socket => { +export const appPolicyIngest: AppPolicy = app => + app.pool.subscribe(socket => { const onReceive = (message: RelayMessage) => { if (!isRelayEvent(message)) return @@ -84,8 +84,8 @@ export const clientPolicyIngest: ClientPolicy = client => if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return if (!verifyEvent(event)) return - client.tracker.track(event.id, socket.url) - client.repository.publish(event) + app.tracker.track(event.id, socket.url) + app.repository.publish(event) } socket.on(SocketEvent.Receive, onReceive) @@ -94,24 +94,24 @@ export const clientPolicyIngest: ClientPolicy = client => }) /** - * Listens to socket activity on the client's pool into the RelayStats store. + * Listens to socket activity on the app's pool into the RelayStats store. */ -export const clientPolicyRelayStats: ClientPolicy = client => { - return client.pool.subscribe(client.use(RelayStats).monitorSocket) +export const appPolicyRelayStats: AppPolicy = app => { + return app.pool.subscribe(app.use(RelayStats).monitorSocket) } /** - * Watches the client's repository for gift wraps (existing and incoming) and + * Watches the app's repository for gift wraps (existing and incoming) and * feeds them to the unwrap queue. */ -export const clientPolicyWraps: ClientPolicy = client => { - const wraps = client.use(Wraps) +export const appPolicyWraps: AppPolicy = app => { + const wraps = app.use(Wraps) - for (const wrap of client.repository.query([{kinds: [WRAP]}])) { + for (const wrap of app.repository.query([{kinds: [WRAP]}])) { wraps.enqueue(wrap) } - return on(client.repository, "update", ({added}: {added: TrustedEvent[]}) => { + return on(app.repository, "update", ({added}: {added: TrustedEvent[]}) => { for (const event of added) { if (event.kind === WRAP) { wraps.enqueue(event) @@ -122,13 +122,13 @@ export const clientPolicyWraps: ClientPolicy = client => { /** * Forwards "message" events from the user's signer to `onMessage`. Opt-in — - * add `clientPolicyLogger(handler)` to a client's `policies`. + * add `makeAppPolicyLogger(handler)` to an app's `policies`. */ -export const makeClientPolicyLogger = - (onMessage: (message: LogMessage) => void): ClientPolicy => - client => { +export const makeAppPolicyLogger = + (onMessage: (message: LogMessage) => void): AppPolicy => + app => { const unsubscribers: Unsubscriber[] = [] - const signer = client.user?.signer + const signer = app.user?.signer if (signer instanceof LoggingSigner) { unsubscribers.push(on(signer, "message", onMessage)) @@ -137,9 +137,9 @@ export const makeClientPolicyLogger = return () => unsubscribers.forEach(call) } -export const defaultClientPolicies: ClientPolicy[] = [ - clientPolicyIngest, - clientPolicyRelayStats, - clientPolicyWraps, - clientPolicyAuthUnlessBlocked, +export const defaultAppPolicies: AppPolicy[] = [ + appPolicyIngest, + appPolicyRelayStats, + appPolicyWraps, + appPolicyAuthUnlessBlocked, ] diff --git a/packages/app/src/profiles.ts b/packages/app/src/profiles.ts deleted file mode 100644 index d4cf030..0000000 --- a/packages/app/src/profiles.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {derived, readable} from "svelte/store" -import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const profilesByPubkey = deriveItemsByKey({ - repository, - eventToItem: readProfile, - filters: [{kinds: [PROFILE]}], - getKey: profile => profile.event.pubkey, -}) - -export const profiles = deriveItems(profilesByPubkey) - -export const getProfilesByPubkey = getter(profilesByPubkey) - -export const getProfiles = getter(profiles) - -export const getProfile = (pubkey: string) => getProfilesByPubkey().get(pubkey) - -export const forceLoadProfile = makeForceLoadItem(makeOutboxLoader(PROFILE), getProfile) - -export const loadProfile = makeLoadItem(makeOutboxLoader(PROFILE), getProfile) - -export const deriveProfile = makeDeriveItem(profilesByPubkey, loadProfile) - -export const displayProfileByPubkey = (pubkey: string | undefined) => - pubkey ? displayProfile(getProfile(pubkey), displayPubkey(pubkey)) : "" - -export const deriveProfileDisplay = (pubkey: string | undefined, ...args: any[]) => - pubkey - ? derived(deriveProfile(pubkey, ...args), $profile => - displayProfile($profile, displayPubkey(pubkey)), - ) - : readable("") diff --git a/packages/app/src/relayLists.ts b/packages/app/src/relayLists.ts deleted file mode 100644 index 8d3f0c9..0000000 --- a/packages/app/src/relayLists.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {chunk, first} from "@welshman/lib" -import { - RELAYS, - asDecryptedEvent, - readList, - TrustedEvent, - sortEventsDesc, - getRelaysFromList, - RelayMode, - Filter, - isPlainReplaceableKind, -} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {load} from "@welshman/net" -import {Router, addMinimalFallbacks} from "@welshman/router" -import {repository} from "./core.js" - -export const fetchRelayList = async (pubkey: string, relayHints: string[] = []) => { - const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}] - - await Promise.all([ - load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}), - load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}), - load({filters, relays: Router.get().Index().getUrls()}), - ]) -} - -export const relayListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [RELAYS]}], - getKey: relayList => relayList.event.pubkey, -}) - -export const relayLists = deriveItems(relayListsByPubkey) - -export const getRelayListsByPubkey = getter(relayListsByPubkey) - -export const getRelayLists = getter(relayLists) - -export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey) - -export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList) - -export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList) - -export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList) - -// Outbox loader - -export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => { - const filters = [{...filter, kinds: [kind], authors: [pubkey]}] - const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write) - const allRelays = Router.get() - .FromRelays(writeRelays) - .policy(addMinimalFallbacks) - .limit(8) - .getUrls() - - if (isPlainReplaceableKind(kind)) { - filters[0].limit = 1 - } - - for (const relays of chunk(2, allRelays)) { - const events = await load({filters, relays}) - - if (events.length > 0) { - return first(sortEventsDesc(events)) - } - } -} - -export const makeOutboxLoader = - (kind: number, filter: Filter = {}, limit = 1) => - async (pubkey: string, relayHints: string[] = []) => { - const filters = [{...filter, kinds: [kind], authors: [pubkey], limit}] - const relays = Router.get().FromRelays(relayHints).getUrls() - - await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)]) - } diff --git a/packages/app/src/relayStats.ts b/packages/app/src/relayStats.ts deleted file mode 100644 index e960590..0000000 --- a/packages/app/src/relayStats.ts +++ /dev/null @@ -1,244 +0,0 @@ -import {writable, Subscriber} from "svelte/store" -import {getter, makeDeriveItem} from "@welshman/store" -import {groupBy, batch, now, ago, DAY, HOUR, MINUTE} from "@welshman/lib" -import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util" -import {Pool, Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net" -import {getBlockedRelayList} from "./blockedRelayLists.js" -import {pubkey} from "./session.js" - -export type RelayStats = { - url: string - first_seen: number - recent_errors: number[] - open_count: number - close_count: number - publish_count: number - request_count: number - event_count: number - last_open: number - last_close: number - last_error: number - last_publish: number - last_request: number - last_event: number - last_auth: number - publish_success_count: number - publish_failure_count: number - eose_count: number - notice_count: number -} - -export const makeRelayStats = (url: string): RelayStats => ({ - url, - first_seen: now(), - recent_errors: [], - open_count: 0, - close_count: 0, - publish_count: 0, - request_count: 0, - event_count: 0, - last_open: 0, - last_close: 0, - last_error: 0, - last_publish: 0, - last_request: 0, - last_event: 0, - last_auth: 0, - publish_success_count: 0, - publish_failure_count: 0, - eose_count: 0, - notice_count: 0, -}) - -export const relayStatsByUrl = writable(new Map()) - -export const getRelayStatsByUrl = getter(relayStatsByUrl) - -export const getRelayStats = (url: string) => getRelayStatsByUrl().get(url) - -export const relayStatsSubscribers: Subscriber[] = [] - -export const notifyRelayStats = (relayStats: RelayStats) => - relayStatsSubscribers.forEach(sub => sub(relayStats)) - -export const onRelayStats = (sub: (relayStats: RelayStats) => void) => { - relayStatsSubscribers.push(sub) - - return () => - relayStatsSubscribers.splice( - relayStatsSubscribers.findIndex(s => s === sub), - 1, - ) -} - -export const deriveRelayStats = makeDeriveItem(relayStatsByUrl) - -export const getRelayQuality = (url: string) => { - // Skip non-relays entirely - if (!isRelayUrl(url)) return 0 - - const $pubkey = pubkey.get() - - if ($pubkey && getRelaysFromList(getBlockedRelayList($pubkey)).includes(url)) return 0 - - const relayStats = getRelayStats(url) - - // If we have recent errors, skip it - if (relayStats) { - if (relayStats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0 - if (relayStats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0 - if (relayStats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0 - } - - // Prefer stuff we're connected to - if (Pool.get().has(url)) return 1 - - // Prefer stuff we've connected to in the past - if (relayStats) return 0.9 - - // If it's not weird url give it an ok score - if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) { - return 0.8 - } - - // Default to a "meh" score - return 0.7 -} - -// Utilities for syncing stats from connections to relays - -type RelayStatsUpdate = [string, (stats: RelayStats) => void] - -const updateRelayStats = batch(1000, (updates: RelayStatsUpdate[]) => { - relayStatsByUrl.update($relayStatsByUrl => { - for (const [url, items] of groupBy(([url]) => url, updates)) { - if (!url || !isRelayUrl(url)) { - console.warn(`Attempted to update stats for an invalid relay url: ${url}`) - continue - } - - const $relayStatsItem: RelayStats = $relayStatsByUrl.get(url) || makeRelayStats(url) - - for (const [_, update] of items) { - update($relayStatsItem) - } - - // Copy so the database gets updated, since we're mutating in updates - const next = {...$relayStatsItem} - $relayStatsByUrl.set(url, next) - notifyRelayStats(next) - } - - return $relayStatsByUrl - }) -}) - -const onSocketSend = ([verb]: ClientMessage, url: string) => { - if (verb === "REQ") { - updateRelayStats([ - url, - stats => { - stats.request_count++ - stats.last_request = now() - }, - ]) - } else if (verb === "EVENT") { - updateRelayStats([ - url, - stats => { - stats.publish_count++ - stats.last_publish = now() - }, - ]) - } -} - -const onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { - if (verb === "OK") { - const [_, ok] = extra - - updateRelayStats([ - url, - stats => { - if (ok) { - stats.publish_success_count++ - } else { - stats.publish_failure_count++ - } - }, - ]) - } else if (verb === "AUTH") { - updateRelayStats([ - url, - stats => { - stats.last_auth = now() - }, - ]) - } else if (verb === "EVENT") { - updateRelayStats([ - url, - stats => { - stats.event_count++ - stats.last_event = now() - }, - ]) - } else if (verb === "EOSE") { - updateRelayStats([ - url, - stats => { - stats.eose_count++ - }, - ]) - } else if (verb === "NOTICE") { - updateRelayStats([ - url, - stats => { - stats.notice_count++ - }, - ]) - } -} - -const onSocketStatus = (status: string, url: string) => { - if (status === SocketStatus.Open) { - updateRelayStats([ - url, - stats => { - stats.last_open = now() - stats.open_count++ - }, - ]) - } - - if (status === SocketStatus.Closed) { - updateRelayStats([ - url, - stats => { - stats.last_close = now() - stats.close_count++ - }, - ]) - } - - if (status === SocketStatus.Error) { - updateRelayStats([ - url, - stats => { - stats.last_error = now() - stats.recent_errors = stats.recent_errors.concat(now()).slice(-100) - }, - ]) - } -} - -export const trackRelayStats = (socket: Socket) => { - socket.on(SocketEvent.Send, onSocketSend) - socket.on(SocketEvent.Receive, onSocketReceive) - socket.on(SocketEvent.Status, onSocketStatus) - - return () => { - socket.off(SocketEvent.Send, onSocketSend) - socket.off(SocketEvent.Receive, onSocketReceive) - socket.off(SocketEvent.Status, onSocketStatus) - } -} diff --git a/packages/app/src/relays.ts b/packages/app/src/relays.ts deleted file mode 100644 index c076a20..0000000 --- a/packages/app/src/relays.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {writable, derived, Subscriber} from "svelte/store" -import {fetchJson, Maybe} from "@welshman/lib" -import {RelayProfile} from "@welshman/util" -import {displayRelayUrl, displayRelayProfile} from "@welshman/util" -import {getter, deriveItems, makeForceLoadItem, makeLoadItem, makeDeriveItem} from "@welshman/store" - -export const relaysByUrl = writable(new Map()) - -export const relays = deriveItems(relaysByUrl) - -export const getRelaysByUrl = getter(relaysByUrl) - -export const getRelays = getter(relays) - -export const getRelay = (url: string) => getRelaysByUrl().get(url) - -export const relaySubscribers: Subscriber[] = [] - -export const notifyRelay = (relay: RelayProfile) => relaySubscribers.forEach(sub => sub(relay)) - -export const onRelay = (sub: (relay: RelayProfile) => void) => { - relaySubscribers.push(sub) - - return () => { - const i = relaySubscribers.findIndex(s => s === sub) - - if (i !== -1) relaySubscribers.splice(i, 1) - } -} - -export const fetchRelay = async (url: string): Promise> => { - try { - const json = await fetchJson(url.replace(/^ws/, "http"), { - headers: { - Accept: "application/nostr+json", - }, - }) - - if (json) { - const info = {...json, url} - - if (!Array.isArray(info.supported_nips)) { - info.supported_nips = [] - } - - info.supported_nips = info.supported_nips.map(String) - - relaysByUrl.update($relaysByUrl => { - $relaysByUrl.set(url, info) - - return $relaysByUrl - }) - - notifyRelay(info) - - return info - } - } catch (e) { - // pass - } -} - -export const forceLoadRelay = makeForceLoadItem(fetchRelay, getRelay) - -export const loadRelay = makeLoadItem(fetchRelay, getRelay) - -export const deriveRelay = makeDeriveItem(relaysByUrl, loadRelay) - -export const displayRelayByPubkey = (url: string) => - displayRelayProfile(getRelay(url), displayRelayUrl(url)) - -export const deriveRelayDisplay = (url: string) => - derived(deriveRelay(url), $relay => displayRelayProfile($relay, displayRelayUrl(url))) diff --git a/packages/app/src/search.ts b/packages/app/src/search.ts deleted file mode 100644 index 7dd7050..0000000 --- a/packages/app/src/search.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Fuse, {IFuseOptions, FuseResult} from "fuse.js" -import {debounce} from "throttle-debounce" -import {derived} from "svelte/store" -import {dec, inc, sortBy} from "@welshman/lib" -import {PROFILE, PublishedProfile, RelayProfile} from "@welshman/util" -import {load} from "@welshman/net" -import {throttled} from "@welshman/store" -import {Router} from "@welshman/router" -import {getWotGraph, getMaxWot} from "./wot.js" -import {profiles} from "./profiles.js" -import {topics, Topic} from "./topics.js" -import {relays} from "./relays.js" -import {handlesByNip05} from "./handles.js" - -export type SearchOptions = { - getValue: (item: T) => V - fuseOptions?: IFuseOptions - onSearch?: (term: string) => void - sortFn?: (items: FuseResult) => any -} - -export type Search = { - options: T[] - getValue: (item: T) => V - getOption: (value: V) => T | undefined - searchOptions: (term: string) => T[] - searchValues: (term: string) => V[] -} - -export const createSearch = (options: T[], opts: SearchOptions): Search => { - const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true}) - const map = new Map(options.map(item => [opts.getValue(item), item])) - - const search = (term: string) => { - opts.onSearch?.(term) - - let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult) - - if (opts.sortFn) { - results = sortBy(opts.sortFn, results) - } - - return results.map(result => result.item) - } - - return { - options, - getValue: opts.getValue, - getOption: (value: V) => map.get(value), - searchOptions: (term: string) => search(term), - searchValues: (term: string) => search(term).map(opts.getValue), - } -} - -export const searchProfiles = debounce(500, (search: string) => { - if (search.length > 2) { - load({ - filters: [{kinds: [PROFILE], search}], - relays: Router.get().Search().getUrls(), - }) - } -}) - -export const profileSearch = derived( - [throttled(800, profiles), throttled(800, handlesByNip05)], - ([$profiles, $handlesByNip05]) => { - // Remove invalid nip05's from profiles - const options = $profiles.map(p => { - const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey - - return isNip05Valid ? p : {...p, nip05: ""} - }) - - return createSearch(options, { - onSearch: searchProfiles, - getValue: (profile: PublishedProfile) => profile.event.pubkey, - sortFn: ({score = 1, item}) => { - const wotScore = getWotGraph().get(item.event.pubkey) || 0 - - return dec(score) * inc(wotScore / getMaxWot()) - }, - fuseOptions: { - keys: [ - "nip05", - {name: "name", weight: 0.8}, - {name: "display_name", weight: 0.5}, - {name: "about", weight: 0.3}, - ], - threshold: 0.3, - shouldSort: false, - }, - }) - }, -) - -export const topicSearch = derived(topics, $topics => - createSearch($topics, { - getValue: (topic: Topic) => topic.name, - fuseOptions: {keys: ["name"]}, - }), -) - -export const relaySearch = derived(relays, $relays => - createSearch($relays, { - getValue: (relay: RelayProfile) => relay.url, - fuseOptions: { - keys: ["url", "name", {name: "description", weight: 0.3}], - }, - }), -) diff --git a/packages/app/src/searchRelayLists.ts b/packages/app/src/searchRelayLists.ts deleted file mode 100644 index 72a61ab..0000000 --- a/packages/app/src/searchRelayLists.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util" -import {TrustedEvent} from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {repository} from "./core.js" -import {makeOutboxLoader} from "./relayLists.js" - -export const searchRelayListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [SEARCH_RELAYS]}], - getKey: searchRelayLists => searchRelayLists.event.pubkey, -}) - -export const searchRelayLists = deriveItems(searchRelayListsByPubkey) - -export const getSearchRelayListsByPubkey = getter(searchRelayListsByPubkey) - -export const getSearchRelayLists = getter(searchRelayLists) - -export const getSearchRelayList = (pubkey: string) => getSearchRelayListsByPubkey().get(pubkey) - -export const forceLoadSearchRelayList = makeForceLoadItem( - makeOutboxLoader(SEARCH_RELAYS), - getSearchRelayList, -) - -export const loadSearchRelayList = makeLoadItem(makeOutboxLoader(SEARCH_RELAYS), getSearchRelayList) - -export const deriveSearchRelayList = makeDeriveItem(searchRelayListsByPubkey, loadSearchRelayList) diff --git a/packages/app/src/session.ts b/packages/app/src/session.ts index 544e843..2685886 100644 --- a/packages/app/src/session.ts +++ b/packages/app/src/session.ts @@ -1,338 +1,82 @@ -import {Client, ClientOptions, PomadeSigner} from "@pomade/core" -import {derived, writable} from "svelte/store" -import {cached, randomId, append, omit, equals, assoc, TaskQueue} from "@welshman/lib" -import {withGetter} from "@welshman/store" -import { - Wallet, - WRAP, - getPubkeyTagValues, - StampedEvent, - SignedEvent, - getPubkey, -} from "@welshman/util" -import { - Nip59, - WrappedSigner, - Nip46Broker, - Nip46Signer, - Nip07Signer, - Nip01Signer, - Nip55Signer, - ISigner, -} from "@welshman/signer" -import {WrapManager} from "@welshman/net" -import {tracker, repository} from "./core.js" +import {Client as PomadeClient, PomadeSigner} from "@pomade/core" +import type {ClientOptions as PomadeClientOptions} from "@pomade/core" +import type {MaybeAsync} from "@welshman/lib" +import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" +import type {ISigner} from "@welshman/signer" -export enum SessionMethod { - Nip01 = "nip01", - Nip07 = "nip07", - Nip46 = "nip46", - Nip55 = "nip55", - Pomade = "pomade", - Pubkey = "pubkey", - Anonymous = "anonymous", +// ── Sessions: serializable {method, data} descriptors ── + +export type Session = { + method: M + data: D } -export type SessionNip01 = { - method: SessionMethod.Nip01 - pubkey: string - secret: string +// ── Session handlers: a method string, its data shape, and how to build a signer ── + +export type SessionHandler = { + method: M + getSigner: (data: D) => MaybeAsync } -export type SessionNip07 = { - method: SessionMethod.Nip07 - pubkey: string -} +/** + * Define a session handler. `M` and `D` are inferred from the arguments, so + * `getSigner` is type-checked against the data shape — and the same handler is + * used to build typed sessions (`toSession`) and to reconstruct signers. + */ +export const defineSessionHandler = (handler: SessionHandler) => handler -export type SessionNip46 = { - method: SessionMethod.Nip46 - pubkey: string - secret: string - handler: { - pubkey: string - relays: string[] - } -} +/** Build a typed, serializable session from a handler and its data. */ +export const toSession = ( + handler: SessionHandler, + data: D, +): Session => ({method: handler.method, data}) -export type SessionNip55 = { - method: SessionMethod.Nip55 - pubkey: string - signer: string -} +// ── Built-in handlers ── -export type SessionPomade = { - method: SessionMethod.Pomade - pubkey: string - clientOptions: ClientOptions - email: string -} - -export type SessionPubkey = { - method: SessionMethod.Pubkey - pubkey: string -} - -export type SessionAnonymous = { - method: SessionMethod.Anonymous -} - -export type SessionAnyMethod = - | SessionNip01 - | SessionNip07 - | SessionNip46 - | SessionNip55 - | SessionPomade - | SessionPubkey - | SessionAnonymous - -export type Session = SessionAnyMethod & {wallet?: Wallet} & Record - -export const pubkey = withGetter(writable(undefined)) - -export const sessions = withGetter(writable>({})) - -export const session = withGetter( - derived([pubkey, sessions], ([$pubkey, $sessions]) => ($pubkey ? $sessions[$pubkey] : undefined)), -) - -export const getSession = (pubkey: string) => sessions.get()[pubkey] - -export const addSession = (session: Session) => { - sessions.update(assoc(session.pubkey, session)) - pubkey.set(session.pubkey) -} - -export const putSession = (session: Session) => { - if (!equals(getSession(session.pubkey), session)) { - sessions.update(assoc(session.pubkey, session)) - } -} - -export const updateSession = (pubkey: string, f: (session: Session) => Session) => - putSession(f(getSession(pubkey))) - -export const dropSession = (_pubkey: string) => { - getSigner.pop(getSession(_pubkey))?.cleanup?.() - pubkey.update($pubkey => ($pubkey === _pubkey ? undefined : $pubkey)) - sessions.update($sessions => omit([_pubkey], $sessions)) -} - -export const clearSessions = () => { - for (const pubkey of Object.keys(sessions.get())) { - dropSession(pubkey) - } -} - -// Session factories - -export const makeNip01Session = (secret: string): SessionNip01 => ({ - method: SessionMethod.Nip01, - secret, - pubkey: getPubkey(secret), +export const nip01 = defineSessionHandler({ + method: "nip01", + getSigner: (data: {secret: string}) => new Nip01Signer(data.secret), }) -export const makeNip07Session = (pubkey: string): SessionNip07 => ({ - method: SessionMethod.Nip07, - pubkey, +export const nip07 = defineSessionHandler({ + method: "nip07", + getSigner: (_data: Record) => new Nip07Signer(), }) -export const makeNip46Session = ( - pubkey: string, - clientSecret: string, - signerPubkey: string, - relays: string[], -): SessionNip46 => ({ - method: SessionMethod.Nip46, - pubkey, - secret: clientSecret, - handler: {pubkey: signerPubkey, relays}, +export const nip46 = defineSessionHandler({ + method: "nip46", + getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) => + new Nip46Signer(new Nip46Broker(data)), }) -export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({ - method: SessionMethod.Nip55, - pubkey, - signer, +export const nip55 = defineSessionHandler({ + method: "nip55", + getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey), }) -export const makePomadeSession = ( - pubkey: string, - email: string, - clientOptions: ClientOptions, -): SessionPomade => ({ - method: SessionMethod.Pomade, - pubkey, - clientOptions, - email, +export const pomade = defineSessionHandler({ + method: "pomade", + getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) => + new PomadeSigner(new PomadeClient(data.clientOptions)), }) -export const makePubkeySession = (pubkey: string): SessionPubkey => ({ - method: SessionMethod.Pubkey, - pubkey, -}) +// ── Registry: deserialize a stored session back into a signer ── -// Type guards +export const sessionHandlers = new Map>() -export const isNip01Session = (session?: Session): session is SessionNip01 => - session?.method === SessionMethod.Nip01 - -export const isNip07Session = (session?: Session): session is SessionNip07 => - session?.method === SessionMethod.Nip07 - -export const isNip46Session = (session?: Session): session is SessionNip46 => - session?.method === SessionMethod.Nip46 - -export const isNip55Session = (session?: Session): session is SessionNip55 => - session?.method === SessionMethod.Nip55 - -export const isPomadeSession = (session?: Session): session is SessionPomade => - session?.method === SessionMethod.Pomade - -export const isPubkeySession = (session?: Session): session is SessionPubkey => - session?.method === SessionMethod.Pubkey - -// Login utilities - -export const loginWithNip01 = (secret: string) => addSession(makeNip01Session(secret)) - -export const loginWithNip07 = (pubkey: string) => addSession(makeNip07Session(pubkey)) - -export const loginWithNip46 = ( - pubkey: string, - clientSecret: string, - signerPubkey: string, - relays: string[], -) => addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays)) - -export const loginWithNip55 = (pubkey: string, signer: string) => - addSession(makeNip55Session(pubkey, signer)) - -export const loginWithPomade = (pubkey: string, email: string, clientOptions: ClientOptions) => - addSession(makePomadeSession(pubkey, email, clientOptions)) - -export const loginWithPubkey = (pubkey: string) => addSession(makePubkeySession(pubkey)) - -// Other stuff - -export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" - -export type SignerLogEntry = { - id: string - method: string - started_at: number - finished_at?: number - ok?: boolean +export const registerSessionHandler = (handler: SessionHandler) => { + sessionHandlers.set(handler.method, handler) } -export const signerLog = withGetter(writable([])) - -export const wrapSigner = (signer: ISigner) => - new WrappedSigner(signer, async (method: string, thunk: () => Promise) => { - const id = randomId() - - signerLog.update(log => append({id, method, started_at: Date.now()}, log)) - - try { - const result = await thunk() - - signerLog.update(log => - log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)), - ) - - return result - } catch (error: any) { - signerLog.update(log => - log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)), - ) - - throw error - } - }) - -export const getSigner = cached({ - maxSize: 100, - getKey: ([session]: [Session | undefined]) => `${session?.method}:${session?.pubkey}`, - getValue: ([session]: [Session | undefined]) => { - if (isNip07Session(session)) return wrapSigner(new Nip07Signer()) - if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret)) - if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey)) - if (isPomadeSession(session)) - return wrapSigner(new PomadeSigner(new Client(session.clientOptions))) - if (isNip46Session(session)) { - const { - secret: clientSecret, - handler: {relays, pubkey: signerPubkey}, - } = session - const broker = new Nip46Broker({clientSecret, signerPubkey, relays}) - const signer = new Nip46Signer(broker) - - return wrapSigner(signer) - } - }, -}) - -export const getSignerFromPubkey = (pubkey: string) => { - const session = getSession(pubkey) - - if (session) { - return getSigner(session) - } +export const unregisterSessionHandler = (handler: SessionHandler) => { + sessionHandlers.delete(handler.method) } -export const signer = withGetter(derived(session, getSigner)) +export const getSignerFromSession = (session: Session): MaybeAsync | undefined => + sessionHandlers.get(session.method)?.getSigner(session.data) -export const sign = (event: StampedEvent) => signer.get()?.sign(event) +// ── Initialize default session handlers ── -export const nip44EncryptToSelf = (payload: string) => { - const $pubkey = pubkey.get() - const $signer = signer.get() - - if (!$signer) { - throw new Error("Unable to encrypt to self without valid signer") - } - - return $signer.nip44.encrypt($pubkey!, payload) -} - -// Gift wrap utilities - -export const wrapManager = new WrapManager({repository, tracker}) - -export const shouldUnwrap = withGetter(writable(false)) - -export const failedUnwraps = new Set() - -export const wrapQueue = new TaskQueue({ - batchSize: 5, - batchDelay: 30, - processItem: async (wrap: SignedEvent) => { - for (const recipient of getPubkeyTagValues(wrap.tags)) { - const signer = getSignerFromPubkey(recipient) - - if (signer) { - try { - const rumor = await Nip59.fromSigner(signer).unwrap(wrap) - - wrapManager.add({wrap, rumor, recipient}) - - return rumor - } catch (e) { - failedUnwraps.add(wrap.id) - } - } - } - }, -}) - -export const unwrapAndStore = async (wrap: SignedEvent) => { - if (wrap.kind !== WRAP) { - throw new Error("Tried to unwrap an invalid event") - } - - if (!shouldUnwrap.get()) { - throw new Error("Discarding wrapped event because `shouldUnwrap` is not enabled") - } - - if (!failedUnwraps.has(wrap.id) && !wrapManager.getRumor(wrap.id)) { - wrapQueue.push(wrap) - } +for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) { + registerSessionHandler(sessionHandler) } diff --git a/packages/app/src/sync.ts b/packages/app/src/sync.ts deleted file mode 100644 index 555e1b7..0000000 --- a/packages/app/src/sync.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type {Filter} from "@welshman/util" -import {isSignedEvent, SignedEvent} from "@welshman/util" -import {push as basePush, pull as basePull, publishOne, requestOne} from "@welshman/net" -import {repository} from "./core.js" -import {getRelay} from "./relays.js" - -const query = (filters: Filter[]) => - repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) - -export const hasNegentropy = (url: string) => { - const relay = getRelay(url) - - if (relay?.negentropy) return true - if (relay?.supported_nips?.includes?.("77")) return true - if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true - - return false -} - -export type AppSyncOpts = { - relays: string[] - filters: Filter[] -} - -export const pull = async ({relays, filters}: AppSyncOpts) => { - const events = query(filters).filter(isSignedEvent) - - await Promise.all( - relays.map(async relay => { - await (hasNegentropy(relay) - ? basePull({filters, events, relays: [relay]}) - : requestOne({filters, relay, autoClose: true})) - }), - ) -} - -export const push = async ({relays, filters}: AppSyncOpts) => { - const events = query(filters).filter(isSignedEvent) - - await Promise.all( - relays.map(async relay => { - await (hasNegentropy(relay) - ? basePush({filters, events, relays: [relay]}) - : Promise.all(events.map((event: SignedEvent) => publishOne({event, relay})))) - }), - ) -} diff --git a/packages/app/src/tags.ts b/packages/app/src/tags.ts deleted file mode 100644 index 7a83117..0000000 --- a/packages/app/src/tags.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {uniq, remove} from "@welshman/lib" -import { - getAddress, - isReplaceable, - getReplyTags, - getPubkeyTagValues, - isReplaceableKind, - isShareableRelayUrl, -} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {Router} from "@welshman/router" -import {displayProfileByPubkey} from "./profiles.js" -import {pubkey} from "./session.js" - -export const tagZapSplit = (pubkey: string, split = 1) => [ - "zap", - pubkey, - Router.get().FromPubkey(pubkey).getUrl() || "", - String(split), -] - -export const tagPubkey = (pubkey: string, ...args: unknown[]) => [ - "p", - pubkey, - Router.get().FromPubkey(pubkey).getUrl() || "", - displayProfileByPubkey(pubkey), -] - -export const tagEvent = (event: TrustedEvent, url = "", mark = "") => { - if (!url) { - url = Router.get().Event(event).getUrl() || "" - } - - const tags = [["e", event.id, url, mark, event.pubkey]] - - if (isReplaceable(event)) { - tags.push(["a", getAddress(event), url, mark, event.pubkey]) - } - - return tags -} - -export const tagEventPubkeys = (event: TrustedEvent) => - uniq(remove(pubkey.get()!, [event.pubkey, ...getPubkeyTagValues(event.tags)])).map(tagPubkey) - -export const tagEventForQuote = (event: TrustedEvent, relay?: string) => { - const hint = relay || Router.get().Event(event).getUrl() || "" - - return ["q", event.id, hint, event.pubkey] -} - -export const tagEventForReply = (event: TrustedEvent, relay?: string) => { - const tags = tagEventPubkeys(event) - const {roots, replies} = getReplyTags(event.tags) - const parents = roots.length > 0 ? roots : replies - const mark = parents.length > 0 ? "reply" : "root" - const hint = relay || Router.get().Event(event).getUrl() || "" - - // If the parent included roots use them, otherwise use replies as a fallback - for (const [k, id, originalHint = "", _, pubkey = ""] of parents) { - const hint = isShareableRelayUrl(originalHint) - ? originalHint - : Router.get().EventRoots(event).getUrl() - - tags.push([k, id, hint || "", "root", pubkey]) - } - - // e-tag the event - tags.push(["e", event.id, hint, mark, event.pubkey]) - - // a-tag the event - if (isReplaceable(event)) { - tags.push(["a", getAddress(event), hint, mark, event.pubkey]) - } - - return tags -} - -export const tagEventForComment = (event: TrustedEvent, relay?: string) => { - const pubkeyHint = Router.get().FromPubkey(event.pubkey).getUrl() || "" - const eventHint = relay || Router.get().Event(event).getUrl() || "" - const address = getAddress(event) - const seenRoots = new Set() - const tags: string[][] = [] - - for (const [t, ...tag] of event.tags) { - if (["K", "E", "A", "I", "P"].includes(t)) { - tags.push([t, ...tag]) - seenRoots.add(t) - } - } - - if (seenRoots.size === 0) { - tags.push(["K", String(event.kind)]) - tags.push(["P", event.pubkey, pubkeyHint]) - tags.push(["E", event.id, eventHint, event.pubkey]) - - if (isReplaceableKind(event.kind)) { - tags.push(["A", address, eventHint, event.pubkey]) - } - } - - tags.push(["k", String(event.kind)]) - tags.push(["p", event.pubkey, pubkeyHint]) - tags.push(["e", event.id, eventHint, event.pubkey]) - - if (isReplaceableKind(event.kind)) { - tags.push(["a", address, eventHint, event.pubkey]) - } - - return tags -} - -export const tagEventForReaction = (event: TrustedEvent, relay?: string) => { - const hint = relay || Router.get().Event(event).getUrl() || "" - const tags: string[][] = [] - - // Mention the event's author - if (event.pubkey !== pubkey.get()) { - tags.push(tagPubkey(event.pubkey)) - } - - tags.push(["k", String(event.kind)]) - tags.push(["e", event.id, hint]) - - if (isReplaceable(event)) { - tags.push(["a", getAddress(event), hint]) - } - - return tags -} diff --git a/packages/app/src/thunk.ts b/packages/app/src/thunk.ts deleted file mode 100644 index bf1c704..0000000 --- a/packages/app/src/thunk.ts +++ /dev/null @@ -1,431 +0,0 @@ -import type {Subscriber} from "svelte/store" -import {writable} from "svelte/store" -import type {Override} from "@welshman/lib" -import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, without} from "@welshman/lib" -import { - HashedEvent, - EventTemplate, - SignedEvent, - isSignedEvent, - WRAPPED_KINDS, - prep, - makePow, -} from "@welshman/util" -import { - publish, - PublishStatus, - PublishResult, - PublishOptions, - PublishResultsByRelay, -} from "@welshman/net" -import {ISigner, Nip01Signer, Nip59} from "@welshman/signer" -import {repository, tracker} from "./core.js" -import {pubkey, signer, wrapManager} from "./session.js" - -export type ThunkOptions = Override< - PublishOptions, - { - event: EventTemplate - recipient?: string - delay?: number - pow?: number - } -> - -export class Thunk { - _subs: Subscriber[] = [] - - pubkey: string - signer: ISigner - event: HashedEvent - results: PublishResultsByRelay = {} - complete = defer() - controller = new AbortController() - wrap?: SignedEvent - - constructor(readonly options: ThunkOptions) { - if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) { - throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`) - } - - const $pubkey = pubkey.get() - - if (!$pubkey) { - throw new Error(`Attempted to publish an event without an active pubkey`) - } - - const $signer = signer.get() - - if (!$signer) { - throw new Error(`Attempted to publish an event without an active signer`) - } - - this.pubkey = $pubkey - this.signer = $signer - this.event = prep(options.event, this.pubkey) - - for (const relay of options.relays) { - this.results[relay] = { - relay, - status: PublishStatus.Sending, - detail: "sending...", - } - } - - this.controller.signal.addEventListener("abort", () => { - for (const relay of options.relays) { - this._setAborted({ - relay, - status: PublishStatus.Aborted, - detail: "aborted", - }) - } - }) - } - - _notify() { - for (const subscriber of this._subs) { - subscriber(this) - } - } - - _fail(detail: string) { - for (const relay of this.options.relays) { - this.results[relay] = { - relay, - status: PublishStatus.Failure, - detail: detail, - } - } - - this._notify() - } - - _setPending = (result: PublishResult) => { - this.options.onPending?.(result) - this.results[result.relay] = result - this._notify() - } - - _setTimeout = (result: PublishResult) => { - this.options.onTimeout?.(result) - this.results[result.relay] = result - this._notify() - } - - _setAborted = (result: PublishResult) => { - this.options.onAborted?.(result) - this.results[result.relay] = result - this._notify() - } - - async _publish(event: SignedEvent) { - // Wait if the thunk is to be delayed - if (this.options.delay) { - await sleep(this.options.delay) - } - - // Skip publishing if aborted - if (this.controller.signal.aborted) { - return - } - - // Send it off - await publish({ - ...this.options, - event, - onSuccess: (result: PublishResult) => { - this.options.onSuccess?.(result) - this.results[result.relay] = result - this._notify() - }, - onFailure: (result: PublishResult) => { - tracker.removeRelay(event.id, result.relay) - this.options.onFailure?.(result) - this.results[result.relay] = result - this._notify() - }, - onPending: this._setPending, - onTimeout: (result: PublishResult) => { - tracker.removeRelay(event.id, result.relay) - this._setTimeout(result) - }, - onAborted: (result: PublishResult) => { - tracker.removeRelay(event.id, result.relay) - this._setAborted(result) - }, - onComplete: (result: PublishResult) => { - this.options.onComplete?.(result) - this._subs = [] - }, - }) - - // Notify the caller that we're done - this.complete.resolve() - } - - async publish() { - // Handle abort immediately if possible - if (this.controller.signal.aborted) return - - const {recipient} = this.options - - // If we're sending it privately, wrap the event using nip 59 - if (recipient) { - const wrapper = Nip01Signer.ephemeral() - const nip59 = new Nip59(this.signer, wrapper) - - this.wrap = await nip59.wrap(recipient, this.event) - - // If we're calculating pow, update the hash and re-sign - if (this.options.pow) { - this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, { - signal: AbortSignal.timeout(30_000), - }) - } - - wrapManager.add({recipient, wrap: this.wrap, rumor: this.event}) - - return this._publish(this.wrap) - } - - // If the event has been signed, we're good to go - if (isSignedEvent(this.event)) { - if (this.options.pow) { - console.warn("Event is already signed, skipping proof of work calculation") - } - - return this._publish(this.event) - } - - // Allow for lazily signing/powing events in order to decrease apparent latency in the UI - // that results from waiting for remote signers - try { - if (this.options.pow) { - this.event = await makePow(this.event, this.options.pow).result - } - - const signedEvent = await this.signer.sign(this.event, { - signal: AbortSignal.timeout(30_000), - }) - - // Update tracker and repository with the signed event since the id will have changed - if (this.options.pow) { - for (const url of this.options.relays) { - tracker.removeRelay(this.event.id, url) - tracker.track(signedEvent.id, url) - } - } - - repository.removeEvent(this.event.id) - repository.publish(signedEvent) - - return this._publish(signedEvent) - } catch (e: any) { - console.error("Failed to sign event", e) - return this._fail(String(e || "Failed to sign event")) - } - } - - enqueue() { - thunkQueue.push(this) - - for (const url of this.options.relays) { - tracker.track(this.event.id, url) - } - - repository.publish(this.event) - thunks.update($thunks => append(this, $thunks)) - - this.controller.signal.addEventListener("abort", () => { - if (this.wrap) { - wrapManager.remove(this.wrap.id) - } else { - repository.removeEvent(this.event.id) - } - - thunks.update($thunks => remove(this, $thunks)) - }) - } - - subscribe(subscriber: Subscriber) { - this._subs.push(subscriber) - - subscriber(this) - - return () => { - this._subs = remove(subscriber, this._subs) - } - } -} - -export class MergedThunk { - _subs: Subscriber[] = [] - - results: PublishResultsByRelay = {} - - constructor(readonly thunks: Thunk[]) { - const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus - const relays = new Set(thunks.flatMap(thunk => thunk.options.relays)) - - for (const thunk of thunks) { - thunk.subscribe($thunk => { - this.results = {} - - for (const relay of relays) { - for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) { - const thunk = thunks.find(t => t.results[relay]?.status === status) - - if (thunk) { - this.results[relay] = thunk.results[relay]! - } - } - } - - this._notify() - - if (thunks.every(thunkIsComplete)) { - this._subs = [] - } - }) - } - } - - _notify() { - for (const subscriber of this._subs) { - subscriber(this) - } - } - - subscribe(subscriber: Subscriber) { - this._subs.push(subscriber) - - subscriber(this) - - return () => { - this._subs = remove(subscriber, this._subs) - } - } -} - -export type AbstractThunk = Thunk | MergedThunk - -export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk - -export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk => - thunk instanceof MergedThunk - -// Thunk status urls - -export const getThunkUrlsWithStatus = ( - statuses: PublishStatus | PublishStatus[], - thunk: AbstractThunk, -) => { - statuses = ensurePlural(statuses) - - return Object.entries(thunk.results) - .filter(([_, {status}]) => statuses.includes(status)) - .map(nth(0)) as string[] -} - -export const getCompleteThunkUrls = (thunk: AbstractThunk) => - getThunkUrlsWithStatus( - without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)), - thunk, - ) - -export const getIncompleteThunkUrls = (thunk: AbstractThunk) => - getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk) - -export const getFailedThunkUrls = (thunk: AbstractThunk) => - getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk) - -// Thunk status checks - -export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) => - getThunkUrlsWithStatus(statuses, thunk).length > 0 - -export const thunkIsComplete = (thunk: AbstractThunk) => - !thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk) - -// Thunk errors - -export const getThunkError = (thunk: Thunk) => { - for (const [_, {status, detail}] of Object.entries(thunk.results)) { - if (status === PublishStatus.Failure) { - return detail - } - } - - if (thunkIsComplete(thunk)) { - return "" - } -} - -// Thunk utilities that return promises - -export const waitForThunkError = (thunk: Thunk) => - new Promise(resolve => { - thunk.subscribe($thunk => { - const error = getThunkError($thunk) - - if (error !== undefined) { - resolve(error) - } - }) - }) - -export const waitForThunkCompletion = (thunk: Thunk) => - new Promise(resolve => { - thunk.subscribe($thunk => { - if (thunkIsComplete($thunk)) { - resolve() - } - }) - }) - -// Thunk state - -export const thunks = writable([]) - -export const thunkQueue = new TaskQueue({ - batchSize: 10, - batchDelay: 100, - processItem: (thunk: Thunk) => { - thunk.publish() - }, -}) - -// Other thunk utilities - -export const mergeThunks = (thunks: AbstractThunk[]) => - new MergedThunk(Array.from(flattenThunks(thunks))) - -export function* flattenThunks(thunks: AbstractThunk[]): Iterable { - for (const thunk of thunks) { - if (isMergedThunk(thunk)) { - yield* flattenThunks(thunk.thunks) - } else { - yield thunk - } - } -} - -export const publishThunk = (options: ThunkOptions) => { - const thunk = new Thunk(options) - - thunk.enqueue() - - return thunk -} - -export const abortThunk = (thunk: AbstractThunk) => { - for (const child of flattenThunks([thunk])) { - child.controller.abort() - } -} - -export const retryThunk = (thunk: AbstractThunk) => - isMergedThunk(thunk) - ? mergeThunks(thunk.thunks.map(t => publishThunk(t.options))) - : publishThunk(thunk.options) diff --git a/packages/app/src/topics.ts b/packages/app/src/topics.ts deleted file mode 100644 index e0846e7..0000000 --- a/packages/app/src/topics.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {readable} from "svelte/store" -import {on, call} from "@welshman/lib" -import {deriveItems} from "@welshman/store" -import {getTopicTagValues} from "@welshman/util" -import {repository} from "./core.js" - -export type Topic = { - name: string - count: number -} - -export const topicsByName = call(() => { - const topicsByName = new Map() - - const addTopic = (name: string) => { - const topic = topicsByName.get(name) - - if (topic) { - topic.count++ - } else { - topicsByName.set(name, {name, count: 1}) - } - } - - for (const tagString of repository.eventsByTag.keys()) { - if (tagString.startsWith("t:")) { - addTopic(tagString.slice(2).toLowerCase()) - } - } - - return readable>(topicsByName, set => { - return on(repository, "update", ({added}) => { - let dirty = false - - for (const event of added) { - for (const name of getTopicTagValues(event.tags)) { - addTopic(name) - dirty = true - } - } - - if (dirty) { - set(topicsByName) - } - }) - }) -}) - -export const topics = deriveItems(topicsByName) diff --git a/packages/app/src/user.ts b/packages/app/src/user.ts index 678f5ef..ef3c429 100644 --- a/packages/app/src/user.ts +++ b/packages/app/src/user.ts @@ -1,94 +1,57 @@ -import {derived, Readable} from "svelte/store" -import {ItemsByKey, deriveDeduplicated} from "@welshman/store" -import {pubkey} from "./session.js" -import {profilesByPubkey, forceLoadProfile, loadProfile} from "./profiles.js" -import {followListsByPubkey, forceLoadFollowList, loadFollowList} from "./follows.js" -import {pinListsByPubkey, forceLoadPinList, loadPinList} from "./pins.js" -import {muteListsByPubkey, forceLoadMuteList, loadMuteList} from "./mutes.js" -import { - blossomServerListsByPubkey, - forceLoadBlossomServerList, - loadBlossomServerList, -} from "./blossom.js" -import {relayListsByPubkey, forceLoadRelayList, loadRelayList} from "./relayLists.js" -import { - messagingRelayListsByPubkey, - forceLoadMessagingRelayList, - loadMessagingRelayList, -} from "./messagingRelayLists.js" -import { - blockedRelayListsByPubkey, - forceLoadBlockedRelayList, - loadBlockedRelayList, -} from "./blockedRelayLists.js" -import { - searchRelayListsByPubkey, - forceLoadSearchRelayList, - loadSearchRelayList, -} from "./searchRelayLists.js" -import {wotGraph, getWotGraph} from "./wot.js" +import type {StampedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" +import {LoggingSigner} from "./logging.js" +import {getSignerFromSession} from "./session.js" +import type {Session} from "./session.js" +import type {IApp} from "./app.js" -export const makeUserData = ( - itemsByKey: Readable>, - onDerive?: (key: string, ...args: any[]) => void, -) => - deriveDeduplicated([itemsByKey, pubkey], ([$itemsByKey, $pubkey]) => { - if (!$pubkey) return undefined +/** + * A single identity: a pubkey plus the signer that proves it. An `App` is + * centered on (at most) one `User`, since the data a user can access depends + * entirely on who they are. + */ +export class User { + constructor( + readonly pubkey: string, + readonly signer: ISigner, + ) {} - onDerive?.($pubkey) - - return $itemsByKey.get($pubkey) - }) - -export const makeUserLoader = - (loadItem: (key: string, ...args: any[]) => void) => - async (...args: any[]) => { - const $pubkey = pubkey.get() - - if ($pubkey) { - await loadItem($pubkey, ...args) + static async fromSigner(signer: ISigner) { + if (!(signer instanceof LoggingSigner)) { + signer = new LoggingSigner(signer) } + + const pubkey = await signer.getPubkey() + + return new User(pubkey, signer) } -export const userProfile = makeUserData(profilesByPubkey, loadProfile) -export const forceLoadUserProfile = makeUserLoader(forceLoadProfile) -export const loadUserProfile = makeUserLoader(loadProfile) + /** + * Reconstruct a signing user from a persisted session, using the registered + * session handlers to find the one for the session's method. The signer is + * wrapped in a `LoggingSigner` (observe it with `makeAppPolicyLogger`) and the + * pubkey is derived from it. Returns undefined when no handler is registered + * for the session's method. + */ + static async fromSession(session: Session): Promise { + const signer = await getSignerFromSession(session) -export const userFollowList = makeUserData(followListsByPubkey, loadFollowList) -export const forceLoadUserFollowList = makeUserLoader(forceLoadFollowList) -export const loadUserFollowList = makeUserLoader(loadFollowList) + return signer ? User.fromSigner(signer) : undefined + } -export const userMuteList = makeUserData(muteListsByPubkey, loadMuteList) -export const forceLoadUserMuteList = makeUserLoader(forceLoadMuteList) -export const loadUserMuteList = makeUserLoader(loadMuteList) + /** + * Return the app's signed-in user, throwing if there isn't one — the entry + * point for actions that can only run as a user (publishing, signing). + */ + static require(app: IApp): User { + if (!app.user) { + throw new Error("This action requires a signed-in user") + } -export const userPinList = makeUserData(pinListsByPubkey, loadPinList) -export const forceLoadUserPinList = makeUserLoader(forceLoadPinList) -export const loadUserPinList = makeUserLoader(loadPinList) + return app.user + } -export const userRelayList = makeUserData(relayListsByPubkey, loadRelayList) -export const forceLoadUserRelayList = makeUserLoader(forceLoadRelayList) -export const loadUserRelayList = makeUserLoader(loadRelayList) + sign = (event: StampedEvent) => this.signer.sign(event) -export const userMessagingRelayList = makeUserData( - messagingRelayListsByPubkey, - loadMessagingRelayList, -) -export const forceLoadUserMessagingRelayList = makeUserLoader(forceLoadMessagingRelayList) -export const loadUserMessagingRelayList = makeUserLoader(loadMessagingRelayList) - -export const userSearchRelayList = makeUserData(searchRelayListsByPubkey, loadSearchRelayList) -export const forceLoadUserSearchRelayList = makeUserLoader(forceLoadSearchRelayList) -export const loadUserSearchRelayList = makeUserLoader(loadSearchRelayList) - -export const userBlockedRelayList = makeUserData(blockedRelayListsByPubkey, loadBlockedRelayList) -export const forceLoadUserBlockedRelayList = makeUserLoader(forceLoadBlockedRelayList) -export const loadUserBlockedRelayList = makeUserLoader(loadBlockedRelayList) - -export const userBlossomServerList = makeUserData(blossomServerListsByPubkey, loadBlossomServerList) -export const forceLoadUserBlossomServerList = makeUserLoader(forceLoadBlossomServerList) -export const loadUserBlossomServerList = makeUserLoader(loadBlossomServerList) - -export const getUserWotScore = (tpk: string) => getWotGraph().get(tpk) || 0 - -export const deriveUserWotScore = (tpk: string) => derived(wotGraph, $g => $g.get(tpk) || 0) + nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload) +} diff --git a/packages/app/src/wot.ts b/packages/app/src/wot.ts deleted file mode 100644 index c50e2d1..0000000 --- a/packages/app/src/wot.ts +++ /dev/null @@ -1,101 +0,0 @@ -import {derived, writable} from "svelte/store" -import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib" -import {getListTags, getPubkeyTagValues} from "@welshman/util" -import {throttled, getter} from "@welshman/store" -import {pubkey} from "./session.js" -import {followLists, getFollowListsByPubkey, getFollowList} from "./follows.js" -import {muteLists, getMuteList} from "./mutes.js" - -export const getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(getFollowList(pubkey))) - -export const getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(getMuteList(pubkey))) - -export const getNetwork = (pubkey: string) => { - const pubkeys = new Set(getFollows(pubkey)) - const network = new Set() - - for (const follow of pubkeys) { - for (const tpk of getFollows(follow)) { - if (!pubkeys.has(tpk)) { - network.add(tpk) - } - } - } - - return Array.from(network) -} - -export const followersByPubkey = derived(throttled(1000, followLists), lists => { - const $followersByPubkey = new Map>() - - for (const list of lists) { - for (const pubkey of getPubkeyTagValues(getListTags(list))) { - addToMapKey($followersByPubkey, pubkey, list.event.pubkey) - } - } - - return $followersByPubkey -}) - -export const getFollowersByPubkey = getter(followersByPubkey) - -export const mutersByPubkey = derived(throttled(1000, muteLists), lists => { - const $mutersByPubkey = new Map>() - - for (const list of lists) { - for (const pubkey of getPubkeyTagValues(getListTags(list))) { - addToMapKey($mutersByPubkey, pubkey, list.event.pubkey) - } - } - - return $mutersByPubkey -}) - -export const getMutersByPubkey = getter(mutersByPubkey) - -export const getFollowers = (pubkey: string) => Array.from(getFollowersByPubkey().get(pubkey) || []) - -export const getMuters = (pubkey: string) => Array.from(getMutersByPubkey().get(pubkey) || []) - -export const getFollowsWhoFollow = (pubkey: string, target: string) => - getFollows(pubkey).filter(other => getFollows(other).includes(target)) - -export const getFollowsWhoMute = (pubkey: string, target: string) => - getFollows(pubkey).filter(other => getMutes(other).includes(target)) - -export const wotGraph = writable(new Map()) - -export const getWotGraph = getter(wotGraph) - -export const maxWot = derived(wotGraph, $g => max(Array.from($g.values()))) - -export const getMaxWot = getter(maxWot) - -const buildGraph = throttle(1000, () => { - const $pubkey = pubkey.get() - const $graph = new Map() - const $follows = $pubkey ? getFollows($pubkey) : getFollowListsByPubkey().keys() - - for (const follow of $follows) { - for (const pubkey of getFollows(follow)) { - $graph.set(pubkey, inc($graph.get(pubkey))) - } - - for (const pubkey of getMutes(follow)) { - $graph.set(pubkey, dec($graph.get(pubkey))) - } - } - - wotGraph.set($graph) -}) - -pubkey.subscribe(buildGraph) -followLists.subscribe(buildGraph) -muteLists.subscribe(buildGraph) - -export const getWotScore = (pubkey: string, target: string) => { - const follows = pubkey ? getFollowsWhoFollow(pubkey, target) : getFollowers(target) - const mutes = pubkey ? getFollowsWhoMute(pubkey, target) : getMuters(target) - - return follows.length - mutes.length -} diff --git a/packages/app/src/zappers.ts b/packages/app/src/zappers.ts deleted file mode 100644 index d645b7e..0000000 --- a/packages/app/src/zappers.ts +++ /dev/null @@ -1,156 +0,0 @@ -import {writable, Subscriber} from "svelte/store" -import {Zapper, TrustedEvent, Zap, getTagValues, zapFromEvent} from "@welshman/util" -import { - removeUndefined, - fetchJson, - bech32ToHex, - hexToBech32, - tryCatch, - batcher, - postJson, -} from "@welshman/lib" -import { - getter, - deriveItems, - deriveDeduplicated, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, -} from "@welshman/store" -import {deriveProfile, loadProfile} from "./profiles.js" -import {appContext} from "./context.js" - -export const zappersByLnurl = writable(new Map()) - -export const zappers = deriveItems(zappersByLnurl) - -export const getZappersByLnurl = getter(zappersByLnurl) - -export const getZapper = (lnurl: string) => getZappersByLnurl().get(lnurl) - -export const zapperSubscribers: Subscriber[] = [] - -export const notifyZapper = (zapper: Zapper) => zapperSubscribers.forEach(sub => sub(zapper)) - -export const onZapper = (sub: (zapper: Zapper) => void) => { - zapperSubscribers.push(sub) - - return () => { - const i = zapperSubscribers.findIndex(s => s === sub) - - if (i !== -1) zapperSubscribers.splice(i, 1) - } -} - -export const fetchZapper = batcher(800, async (lnurls: string[]) => { - const base = appContext.dufflepudUrl - const result = new Map() - const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1")) - - const addZapper = (lnurl: string, info: any) => { - if (info) { - try { - result.set(lnurl, {...info, lnurl}) - } catch (e) { - // pass - } - } - } - - // Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly - if (base) { - const hexUrls = valid.map(bech32ToHex) - const res = await tryCatch(() => postJson(`${base}/zapper/info`, {lnurls: hexUrls})) - - for (const {lnurl, info} of res?.data || []) { - addZapper(hexToBech32("lnurl", lnurl), info) - } - } else { - await Promise.all( - valid.map(async lnurl => { - addZapper(lnurl, await tryCatch(() => fetchJson(bech32ToHex(lnurl)))) - }), - ) - } - - if (result.size > 0) { - zappersByLnurl.update($zappersByLnurl => { - for (const [lnurl, zapper] of result) { - $zappersByLnurl.set(lnurl, zapper) - } - - return $zappersByLnurl - }) - - for (const zapper of result.values()) { - notifyZapper(zapper) - } - } - - return lnurls.map(lnurl => result.get(lnurl)) -}) - -export const forceLoadZapper = makeForceLoadItem(fetchZapper, getZapper) - -export const loadZapper = makeLoadItem(fetchZapper, getZapper) - -export const deriveZapper = makeDeriveItem(zappersByLnurl, loadZapper) - -export const loadZapperForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await loadProfile(pubkey, relays) - - return $profile?.lnurl ? loadZapper($profile.lnurl) : undefined -} - -export const deriveZapperForPubkey = (pubkey: string, relays: string[] = []) => { - loadZapperForPubkey(pubkey, relays) - - return deriveDeduplicated( - [zappersByLnurl, deriveProfile(pubkey, relays)], - ([$zappersByLnurl, $profile]) => { - return $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined - }, - ) -} - -export const getLnUrlsForEvent = async (event: TrustedEvent) => { - const pubkeys = getTagValues("zap", event.tags) - - if (pubkeys.length > 0) { - const profiles = await Promise.all(pubkeys.map(pubkey => loadProfile(pubkey))) - const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl)) - - if (lnurls.length > 0) { - return lnurls - } - } - - const profile = await loadProfile(event.pubkey) - - return removeUndefined([profile?.lnurl]) -} - -export const getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => { - const lnurls = await getLnUrlsForEvent(parent) - - return lnurls.length > 0 ? loadZapper(lnurls[0]) : undefined -} - -export const getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => { - const zapper = await getZapperForZap(zap, parent) - - return zapper ? zapFromEvent(zap, zapper) : undefined -} - -export const getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) => - removeUndefined(await Promise.all(zaps.map(zap => getValidZap(zap, parent)))) - -export const deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => { - const store = writable([]) - - getValidZaps(zaps, parent).then(validZaps => { - store.set(validZaps) - }) - - return store -} diff --git a/packages/app/tsconfig.build.json b/packages/app/tsconfig.build.json index 524ea02..ba91221 100644 --- a/packages/app/tsconfig.build.json +++ b/packages/app/tsconfig.build.json @@ -7,6 +7,7 @@ "@welshman/feeds": ["../feeds/src/index.js"], "@welshman/lib": ["../lib/src/index.js"], "@welshman/net": ["../net/src/index.js"], + "@welshman/router": ["../router/src/index.js"], "@welshman/signer": ["../signer/src/index.js"], "@welshman/store": ["../store/src/index.js"], "@welshman/util": ["../util/src/index.js"] diff --git a/packages/client/.eslintignore b/packages/client/.eslintignore deleted file mode 100644 index 4c72c12..0000000 --- a/packages/client/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -build -normalize-url -__tests__ \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json deleted file mode 100644 index 6b92cac..0000000 --- a/packages/client/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@welshman/client", - "version": "0.8.13", - "author": "hodlbod", - "license": "MIT", - "description": "An instance-based, composable client for building nostr applications", - "publishConfig": { - "access": "public" - }, - "type": "module", - "main": "dist/client/src/index.js", - "types": "dist/client/src/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "pnpm run clean && pnpm run compile --force", - "clean": "rimraf ./dist", - "compile": "tsc -b tsconfig.build.json", - "prepublishOnly": "pnpm run build" - }, - "dependencies": { - "fuse.js": "^7.0.0", - "throttle-debounce": "^5.0.2" - }, - "peerDependencies": { - "@pomade/core": "^0.2.1", - "@welshman/feeds": "workspace:*", - "@welshman/lib": "workspace:*", - "@welshman/net": "workspace:*", - "@welshman/router": "workspace:*", - "@welshman/signer": "workspace:*", - "@welshman/store": "workspace:*", - "@welshman/util": "workspace:*", - "svelte": "^4.0.0 || ^5.0.0" - }, - "devDependencies": { - "rimraf": "~6.0.0", - "typescript": "~5.8.0", - "@pomade/core": "^0.2.1", - "@types/throttle-debounce": "^5.0.2", - "@welshman/feeds": "workspace:*", - "@welshman/lib": "workspace:*", - "@welshman/net": "workspace:*", - "@welshman/router": "workspace:*", - "@welshman/signer": "workspace:*", - "@welshman/store": "workspace:*", - "@welshman/util": "workspace:*", - "svelte": "^5.39.12" - } -} diff --git a/packages/client/src/createApp.ts b/packages/client/src/createApp.ts deleted file mode 100644 index 91a75cc..0000000 --- a/packages/client/src/createApp.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {Client} from "./client.js" -import type {ClientOptions} from "./client.js" -import {defaultClientPolicies} from "./policy.js" - -/** - * Creates a batteries-included client: a `Client` wired with the default client - * policies (event ingestion, relay-stats collection, gift-wrap unwrapping). - * Reach data modules via `client.use(Profiles)`, `client.use(FollowLists)`, etc. - * - * For a bare client (no default side effects) construct `new Client(...)` - * directly, or pass your own `policies`. - */ -export const createApp = (options: ClientOptions = {}) => - new Client({...options, policies: options.policies ?? defaultClientPolicies}) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts deleted file mode 100644 index a165284..0000000 --- a/packages/client/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -export * from "./client.js" -export * from "./policy.js" -export * from "./user.js" -export * from "./session.js" -export * from "./logging.js" -export * from "./createApp.js" -export * from "./plugins/base.js" -export * from "./plugins/network.js" -export * from "./plugins/stores.js" -export * from "./plugins/router.js" -export * from "./plugins/relays.js" -export * from "./plugins/relayStats.js" -export * from "./plugins/relayLists.js" -export * from "./plugins/blockedRelayLists.js" -export * from "./plugins/plaintext.js" -export * from "./plugins/profiles.js" -export * from "./plugins/follows.js" -export * from "./plugins/mutes.js" -export * from "./plugins/pins.js" -export * from "./plugins/blossom.js" -export * from "./plugins/messagingRelayLists.js" -export * from "./plugins/searchRelayLists.js" -export * from "./plugins/handles.js" -export * from "./plugins/zappers.js" -export * from "./plugins/topics.js" -export * from "./plugins/tags.js" -export * from "./plugins/wot.js" -export * from "./plugins/feeds.js" -export * from "./plugins/search.js" -export * from "./plugins/sync.js" -export * from "./plugins/wraps.js" -export * from "./plugins/rooms.js" -export * from "./plugins/relayManagement.js" -export * from "./plugins/thunk.js" diff --git a/packages/client/src/plugins/router.ts b/packages/client/src/plugins/router.ts deleted file mode 100644 index 2e96dd5..0000000 --- a/packages/client/src/plugins/router.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {RelayMode} from "@welshman/util" -import {Router as BaseRouter} from "@welshman/router" -import {RelayLists} from "./relayLists.js" -import {RelayStats} from "./relayStats.js" -import type {IClient} from "../client.js" - -/** - * The upstream `@welshman/router` Router, wired to this client: relay lists come - * from the `RelayLists` collection, quality from `RelayStats`, and the user - * pubkey + relay-getters from the client (via `ctx.config`). Reach it via - * `client.use(Router)`. This replaces the old forked copy — one source of truth, - * no global `routerContext`/`Router.get()`. - */ -export class Router extends BaseRouter { - constructor(ctx: IClient) { - super({ - getUserPubkey: () => ctx.user?.pubkey, - getPubkeyRelays: (pubkey, mode) => - (mode === RelayMode.Read - ? ctx.use(RelayLists).readUrls(pubkey) - : ctx.use(RelayLists).writeUrls(pubkey) - ).get(), - getRelayQuality: url => ctx.use(RelayStats).getQuality(url), - getDefaultRelays: ctx.config.getDefaultRelays, - getIndexerRelays: ctx.config.getIndexerRelays, - getSearchRelays: ctx.config.getSearchRelays, - }) - } -} diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts deleted file mode 100644 index 2685886..0000000 --- a/packages/client/src/session.ts +++ /dev/null @@ -1,82 +0,0 @@ -import {Client as PomadeClient, PomadeSigner} from "@pomade/core" -import type {ClientOptions as PomadeClientOptions} from "@pomade/core" -import type {MaybeAsync} from "@welshman/lib" -import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" -import type {ISigner} from "@welshman/signer" - -// ── Sessions: serializable {method, data} descriptors ── - -export type Session = { - method: M - data: D -} - -// ── Session handlers: a method string, its data shape, and how to build a signer ── - -export type SessionHandler = { - method: M - getSigner: (data: D) => MaybeAsync -} - -/** - * Define a session handler. `M` and `D` are inferred from the arguments, so - * `getSigner` is type-checked against the data shape — and the same handler is - * used to build typed sessions (`toSession`) and to reconstruct signers. - */ -export const defineSessionHandler = (handler: SessionHandler) => handler - -/** Build a typed, serializable session from a handler and its data. */ -export const toSession = ( - handler: SessionHandler, - data: D, -): Session => ({method: handler.method, data}) - -// ── Built-in handlers ── - -export const nip01 = defineSessionHandler({ - method: "nip01", - getSigner: (data: {secret: string}) => new Nip01Signer(data.secret), -}) - -export const nip07 = defineSessionHandler({ - method: "nip07", - getSigner: (_data: Record) => new Nip07Signer(), -}) - -export const nip46 = defineSessionHandler({ - method: "nip46", - getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) => - new Nip46Signer(new Nip46Broker(data)), -}) - -export const nip55 = defineSessionHandler({ - method: "nip55", - getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey), -}) - -export const pomade = defineSessionHandler({ - method: "pomade", - getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) => - new PomadeSigner(new PomadeClient(data.clientOptions)), -}) - -// ── Registry: deserialize a stored session back into a signer ── - -export const sessionHandlers = new Map>() - -export const registerSessionHandler = (handler: SessionHandler) => { - sessionHandlers.set(handler.method, handler) -} - -export const unregisterSessionHandler = (handler: SessionHandler) => { - sessionHandlers.delete(handler.method) -} - -export const getSignerFromSession = (session: Session): MaybeAsync | undefined => - sessionHandlers.get(session.method)?.getSigner(session.data) - -// ── Initialize default session handlers ── - -for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) { - registerSessionHandler(sessionHandler) -} diff --git a/packages/client/src/user.ts b/packages/client/src/user.ts deleted file mode 100644 index 63cfaf7..0000000 --- a/packages/client/src/user.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type {StampedEvent} from "@welshman/util" -import type {ISigner} from "@welshman/signer" -import {LoggingSigner} from "./logging.js" -import {getSignerFromSession} from "./session.js" -import type {Session} from "./session.js" -import type {IClient} from "./client.js" - -/** - * A single identity: a pubkey plus the signer that proves it. A `Client` is - * centered on (at most) one `User`, since the data a user can access depends - * entirely on who they are. - */ -export class User { - constructor( - readonly pubkey: string, - readonly signer: ISigner, - ) {} - - static async fromSigner(signer: ISigner) { - if (!(signer instanceof LoggingSigner)) { - signer = new LoggingSigner(signer) - } - - const pubkey = await signer.getPubkey() - - return new User(pubkey, signer) - } - - /** - * Reconstruct a signing user from a persisted session, using the registered - * session handlers to find the one for the session's method. The signer is - * wrapped in a `LoggingSigner` (observe it with `clientPolicyLogger`) and the - * pubkey is derived from it. Returns undefined when no handler is registered - * for the session's method. - */ - static async fromSession(session: Session): Promise { - const signer = await getSignerFromSession(session) - - return signer ? User.fromSigner(signer) : undefined - } - - /** - * Return the client's signed-in user, throwing if there isn't one — the entry - * point for actions that can only run as a user (publishing, signing). - */ - static require(ctx: IClient): User { - if (!ctx.user) { - throw new Error("This action requires a signed-in user") - } - - return ctx.user - } - - sign = (event: StampedEvent) => this.signer.sign(event) - - nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload) -} diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json deleted file mode 100644 index ba91221..0000000 --- a/packages/client/tsconfig.build.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.build.json", - - "compilerOptions": { - "outDir": "./dist", - "paths": { - "@welshman/feeds": ["../feeds/src/index.js"], - "@welshman/lib": ["../lib/src/index.js"], - "@welshman/net": ["../net/src/index.js"], - "@welshman/router": ["../router/src/index.js"], - "@welshman/signer": ["../signer/src/index.js"], - "@welshman/store": ["../store/src/index.js"], - "@welshman/util": ["../util/src/index.js"] - } - }, - - "include": [ - "src/**/*" - ] -} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json deleted file mode 100644 index 4082f16..0000000 --- a/packages/client/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../../tsconfig.json" -} diff --git a/skills/README.md b/skills/README.md index 423bd82..e436fce 100644 --- a/skills/README.md +++ b/skills/README.md @@ -26,7 +26,7 @@ npx skills add coracle-social/welshman | welshman-store | Svelte stores and Repository pattern | | welshman-signer | Signing, login methods, encrypted events | | welshman-feeds | Dynamic feed construction | -| welshman-app | High-level app-layer Svelte stores | +| welshman-app | Instance-based client: plugins, sessions, publishing, requests | | welshman-content | Note content parsing and rendering | | welshman-editor | Svelte rich-text editor component | diff --git a/skills/welshman-app/SKILL.md b/skills/welshman-app/SKILL.md index 9b35287..1545513 100644 --- a/skills/welshman-app/SKILL.md +++ b/skills/welshman-app/SKILL.md @@ -1,11 +1,15 @@ --- name: welshman-app -description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer." +description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search." --- -# welshman/app — Application Layer Stores +# welshman/app — Instance-Based Nostr App -`@welshman/app` is the top-level application framework in the welshman stack. It wires together `@welshman/net` (subscriptions/publishing), `@welshman/store` (reactive collections), `@welshman/router` (relay selection), and `@welshman/signer` (key management) into ready-to-use Svelte stores and high-level utilities. It powers production apps like Coracle and Flotilla. +## Overview + +`@welshman/app` is the high-level app layer of welshman. It ties `util`, `net`, `store`, `router`, `signer`, and `feeds` together behind a single **`App`** instance. Everything — the event repository, connection pool, the signed-in user, and all features — hangs off that instance. There are **no module-level globals**: you create an app and reach everything through `app.use(...)`. + +This is a redesign of the older global-singleton API. If you see code using `pubkey`, `deriveProfile`, `publishThunk`, `addSession`, or `Router.get()` as importable globals, that is the **old** API — it no longer exists. The current API is instance-based (see the migration table at the bottom). ## Installation @@ -16,619 +20,275 @@ pnpm add @welshman/app yarn add @welshman/app ``` -## Key Exports +Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade/core`. -### Core Singletons +## Core mental model -| Export | Description | -|---|---| -| `repository` | Singleton `Repository` from `@welshman/net`; non-DVM, non-ephemeral events received from the pool are stored here. WRAP (NIP-59) events are handled separately via `unwrapAndStore` and require `shouldUnwrap` to be set to `true` to process. | -| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on | +1. **An app is an `App` instance.** It owns per-identity state (`repository`, `pool`, `tracker`, `wrapManager`), a `config`, and at most one `User`. Two apps never share data. +2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app. +3. **`Projection` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths. +4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive. +5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo. -### Session Management - -| Export | Description | -|---|---| -| `pubkey` | `Writable` — active session's pubkey | -| `session` | `Readable` — derived from `pubkey` + `sessions` | -| `sessions` | `Writable>` — all loaded sessions | -| `signer` | `Readable` — signer for the active session | -| `signerLog` | `WritableWithGetter` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) | -| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` | - -**Login functions** (all call `addSession` internally): +## Creating an app ```typescript -loginWithNip01(secret: string): void -loginWithNip07(pubkey: string): void -loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void -loginWithNip55(pubkey: string, signerPackageName: string): void -loginWithPomade(pubkey, email, clientOptions): void -loginWithPubkey(pubkey: string): void // read-only -``` +import {createApp} from "@welshman/app" -**Session utilities**: - -```typescript -addSession(session: Session): void // add and activate -dropSession(pubkey: string): void // remove and clean up signer -getSession(pubkey: string): Session | undefined -updateSession(pubkey, fn): void -clearSessions(): void -nip46Perms: string // default NIP-46 permission string -``` - -**Gift wrap (NIP-59)**: - -```typescript -shouldUnwrap: Writable // must be true to process incoming wraps -wrapManager: WrapManager // tracks wrap↔rumor mappings -unwrapAndStore(wrap: SignedEvent): Promise -``` - -### Publishing — Thunks - -| Export | Description | -|---|---| -| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event | -| `Thunk` | Class representing a single in-flight publish | -| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) | -| `thunks` | `Writable` — all active thunks | -| `abortThunk(thunk)` | Abort all constituent thunks | -| `retryThunk(thunk)` | Re-publish with original options | -| `mergeThunks(thunks[])` | Combine into a `MergedThunk` | - -**Thunk status helpers**: - -```typescript -thunkIsComplete(thunk): boolean -getThunkError(thunk): string | undefined -getThunkUrlsWithStatus(statuses, thunk): string[] -getCompleteThunkUrls(thunk): string[] -getFailedThunkUrls(thunk): string[] -waitForThunkError(thunk): Promise -waitForThunkCompletion(thunk): Promise -``` - -`ThunkOptions`: -```typescript -type ThunkOptions = { - event: EventTemplate // unsigned — will be signed lazily - relays: string[] - recipient?: string // if set, event is NIP-59 gift-wrapped - delay?: number // ms to wait before sending (abort window) - pow?: number // proof-of-work difficulty target - timeout?: number // ms per relay before marking as timed out - // PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete -} -``` - -### Commands (Higher-level Thunk Factories) - -Most return a `Thunk` (or `Promise`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise` (an HTTP response from the NIP-86 management endpoint), not a Thunk. - -| Export | Description | -|---|---| -| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata | -| `follow(tag: string[])` | Add to NIP-02 follow list | -| `unfollow(value: string)` | Remove from follow list | -| `mutePublicly(tag)` | Add to public mute list | -| `mutePrivately(tag)` | Add to private (encrypted) mute list | -| `unmute(value)` | Remove from mute list | -| `setMutes({publicTags?, privateTags?})` | Replace entire mute list | -| `pin(tag)` / `unpin(value)` | Manage pin list | -| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management | -| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates | -| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list | -| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list | -| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list | -| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients | -| `manageRelay(url, request)` | NIP-86 relay management | -| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management | - -### Profiles - -```typescript -profilesByPubkey: Readable> -profiles: Readable -getProfile(pubkey: string): Profile | undefined -loadProfile(pubkey, relayHints?): Promise -forceLoadProfile(pubkey, relayHints?): Promise -deriveProfile(pubkey: string): Readable // auto-loads -deriveProfileDisplay(pubkey: string): Readable // display name with fallback -displayProfileByPubkey(pubkey: string): string // synchronous -``` - -### Follow / Mute / Pin Lists - -Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical): - -```typescript -followListsByPubkey: Readable> -followLists: Readable -getFollowList(pubkey): List | undefined -loadFollowList(pubkey, relayHints?): Promise -forceLoadFollowList(pubkey, relayHints?): Promise -deriveFollowList(pubkey): Readable -``` - -### Relay Lists - -```typescript -// NIP-65 relay lists -relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList - -// NIP-17 messaging relay lists -messagingRelayListsByPubkey / messagingRelayLists / getMessagingRelayList / loadMessagingRelayList / deriveMessagingRelayList - -// Blocked relay lists -blockedRelayListsByPubkey / getBlockedRelayList / loadBlockedRelayList / deriveBlockedRelayList - -// Search relay lists (internal only — not exported from @welshman/app) -// Use userSearchRelayList / loadUserSearchRelayList / forceLoadUserSearchRelayList from user.ts instead -``` - -### Outbox Loading - -`makeOutboxLoader` creates a loader function for any event kind. It looks up the target pubkey's relay list (fetching it if needed), then fetches events from their write relays using the outbox model. This is the internal mechanism used by all built-in `loadX` helpers. - -```typescript -import {makeOutboxLoader} from '@welshman/app' - -// Signature: makeOutboxLoader(kind, filter?, limit?) -// Returns: (pubkey: string, relayHints?: string[]) => Promise -// Results are stored in repository — read via the derived store/getter, not the return value. - -// Loader for kind 1 notes (default limit = 1) -const loadNote = makeOutboxLoader(1) -await loadNote('target-pubkey') - -// With extra filter constraints -const loadRecentNotes = makeOutboxLoader(1, {since: Math.floor(Date.now() / 1000) - 86400}) -await loadRecentNotes('target-pubkey') - -// Override the limit via the third positional argument (not inside the filter object) -const loadMany = makeOutboxLoader(1, {}, 20) -await loadMany('target-pubkey') - -// With relay hints to seed the lookup -await loadNote('target-pubkey', ['wss://relay.damus.io/']) -``` - -**Relay URL helpers** (exported from index): - -```typescript -getPubkeyRelays(pubkey: string, mode?: RelayMode): string[] -derivePubkeyRelays(pubkey: string, mode?: RelayMode): Readable -``` - -### User Data Stores (current session) - -These automatically derive from the active `pubkey` and trigger a load on first access: - -```typescript -userProfile: Readable -userFollowList: Readable -userMuteList: Readable -userPinList: Readable -userRelayList: Readable -userMessagingRelayList: Readable -userSearchRelayList: Readable -userBlockedRelayList: Readable -userBlossomServerList: Readable -``` - -Corresponding loaders (operate on the current session's pubkey): - -```typescript -loadUserProfile(relays?) -forceLoadUserProfile(relays?) -loadUserFollowList / forceLoadUserFollowList -loadUserMuteList / forceLoadUserMuteList -loadUserPinList / forceLoadUserPinList -loadUserRelayList / forceLoadUserRelayList -loadUserMessagingRelayList / forceLoadUserMessagingRelayList -// ...etc for each list type -``` - -### Router - -```typescript -import {Router, routerContext, addMaximalFallbacks, addMinimalFallbacks} from '@welshman/router' - -// The index.ts wires up routerContext automatically: -// routerContext.getUserPubkey, getPubkeyRelays, getRelayQuality, getDefaultRelays, etc. - -Router.get() // singleton with app-wired context -Router.get().FromUser() // relays to publish from the current user -Router.get().ForPubkey(pubkey) // relays to read a pubkey's events -Router.get().Event(event) // best relay for a specific event -Router.get().Index() // indexer/bootstrap relays -Router.get().FromRelays(urls) // relay set from explicit URLs - .policy(addMaximalFallbacks) // add fallback relays - .limit(8) - .getUrls() // string[] - .getUrl() // string | undefined (first) -``` - -`routerContext` settings (configure before using router): - -```typescript -import {routerContext} from '@welshman/router' // from @welshman/router, not @welshman/app - -routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"] -``` - -### Tag Utilities - -```typescript -tagPubkey(pubkey: string): string[] // ["p", pubkey, relayHint, displayName] -tagEvent(event, url?, mark?): string[][] // e-tag (+ a-tag if replaceable) -tagEventPubkeys(event): string[][] // p-tags for all mentioned pubkeys (excl. self) -tagEventForQuote(event, relay?): string[] // q-tag -tagEventForReply(event, relay?): string[][] // full reply thread tags -tagEventForComment(event, relay?): string[][]// NIP-22 comment tags -tagEventForReaction(event, relay?): string[][]// reaction tags -tagZapSplit(pubkey, split?): string[] // zap tag -``` - -### Web of Trust (WoT) - -```typescript -// Reactive stores -followersByPubkey: Readable>> -mutersByPubkey: Readable>> -wotGraph: Writable> // pubkey → score; rebuilt on follow/mute changes -maxWot: Readable - -// Synchronous getters -getFollows(pubkey): string[] -getMutes(pubkey): string[] -getFollowers(pubkey): string[] -getMuters(pubkey): string[] -getNetwork(pubkey): string[] // follows-of-follows (excludes direct follows) -getFollowsWhoFollow(pubkey, target): string[] -getFollowsWhoMute(pubkey, target): string[] -getWotScore(pubkey, target): number // follows-who-follow minus follows-who-mute - -// Per-user reactive score -getUserWotScore(tpk: string): number -deriveUserWotScore(tpk: string): Readable -``` - -### Handles & Zappers - -```typescript -handlesByNip05: Writable> -deriveHandle(nip05: string): Readable // auto-loads -loadHandle(nip05): Promise - -zappersByLnurl: Writable> -deriveZapper(lnurl: string): Readable // auto-loads -loadZapper(lnurl): Promise -``` - -### Feeds - -```typescript -import {makeFeedController} from '@welshman/app' -import {makeKindFeed} from '@welshman/feeds' - -// makeFeedController wraps FeedController with app-level scope/WoT helpers -const ctrl = makeFeedController({ - feed: makeKindFeed(NOTE), - useWindowing: true, - signal: abortController.signal, - onEvent: (e) => { /* handle event */ }, - onExhausted: () => { /* no more events */ }, +// Batteries-included: installs default policies (event ingestion, relay stats, +// gift-wrap unwrapping, NIP-42 auth-unless-blocked). +const app = createApp({ + user, // optional User + config: { + dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups + getDefaultRelays: () => [...], + getIndexerRelays: () => [...], // discovery relays for profiles/relay lists + getSearchRelays: () => [...], // NIP-50 search relays + }, }) -ctrl.load(100) -abortController.abort() +// Bare app with NO side effects (tests, or custom policies): +import {App} from "@welshman/app" +const bare = new App() + +// Always tear down when discarding an app (e.g. switching identities): +app.cleanup() ``` -WoT-scoped feed helpers (passed automatically to `FeedController`): +`IApp` (what plugins/policies depend on): `{user?, config, use, netContext, pool, tracker, repository, wrapManager}`. + +## User & sessions + +A `User` is `{pubkey, signer}`. A `Session` is a serializable `{method, data}` descriptor you persist; session handlers turn it back into a signer. ```typescript -getPubkeysForScope(scope: string): string[] // Scope.Self|Follows|Network|Followers -getPubkeysForWOTRange(min: number, max: number): string[] // fractional of maxWot +import {createApp, User, toSession, nip07} from "@welshman/app" +import {getNip07} from "@welshman/signer" + +// Build a User from a live signer... +const user = await User.fromSigner(getNip07()) + +// ...or from a persisted session +const session = toSession(nip07, {}) // serializable, store this +localStorage.setItem("session", JSON.stringify(session)) +const restored = await User.fromSession(JSON.parse(localStorage.getItem("session")!)) // User | undefined + +const app = createApp({user: restored}) + +// Gate user-only actions (throws if no user): +const u = User.require(app) +await u.sign(stampedEvent) +await u.nip44EncryptToSelf(payload) // encrypt to self (private list entries) ``` -### Sync (Negentropy) +Built-in session handlers (auto-registered): `nip01` `{secret}`, `nip07` `{}`, `nip46` `{clientSecret, signerPubkey, relays}`, `nip55` `{pubkey, signer}`, `pomade` `{clientOptions, email}`. Register custom ones with `defineSessionHandler` + `registerSessionHandler`. + +## Data plugins (reactive collections) + +All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`. + +| Plugin | Data | Notable accessors | +|---|---|---| +| `Profiles` | kind-0 profiles | `one(pk)`, `display(pk)`, `publish(profile)` | +| `FollowLists` | kind-3 follows | `one(pk)`, `follow(tag)`, `unfollow(value)` | +| `MuteLists` | kind-10000 mutes (private = encrypted) | `mutePublicly(tag)`, `mutePrivately(tag)`, `unmute(v)`, `setMutes(...)` | +| `PinLists` | kind-10001 pins | `pin(tag)`, `unpin(value)` | +| `RelayLists` | NIP-65 (kind 10002) | `urls(pk)`, `readUrls(pk)`, `writeUrls(pk)`, `addRelay(url, mode)`, `setWriteRelays(urls)` | +| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addRelay`, `removeRelay`, `setRelays` | +| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addRelay`, ... | +| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addRelay`, ... | +| `Relays` | NIP-11 relay info (HTTP) | `one(url)`, `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)` | +| `RelayManagement` | NIP-86 | `post(url, request)` | +| `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` | +| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validZapReceipts(...)` | +| `BlossomServerLists` | kind-10063 media servers | `one(pk)`, `load(pk)` | +| `Topics` | hashtags w/ counts | `all`, `byName` (plain `Readable`s) | +| `Rooms` | NIP-29 groups | `create/edit/delete/join/leave/addMember/removeMember(url, room, ...)` | +| `Plaintext` | decrypted-content cache (own events) | `ensure(event)`, `get(id)` | ```typescript -import {pull, push, hasNegentropy} from '@welshman/app' +import {createApp, Profiles, RelayLists} from "@welshman/app" +const app = createApp({user}) -// pull/push use negentropy if the relay supports it, falling back to plain requests -await pull({relays, filters}) -await push({relays, filters}) -hasNegentropy(url: string): boolean +// Reactive (Svelte): subscribe or use $ in a component +const profile$ = app.use(Profiles).one(pubkey) // Readable>, lazy-loads +const name$ = app.use(Profiles).display(pubkey).$ // Readable + +// Synchronous snapshot (no load) +const profileNow = app.use(Profiles).get(pubkey) + +// Explicit load +await app.use(Profiles).load(pubkey) + +// Relay selections (outbox model) +const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[] +await app.use(RelayLists).addRelay("wss://relay.example", RelayMode.Write) ``` -### Application Context +## Publishing (optimistic thunks) ```typescript -import {appContext} from '@welshman/app' +import {Thunks} from "@welshman/app" +import {makeEvent, NOTE} from "@welshman/util" -appContext.dufflepudUrl = 'https://my-dufflepud.example.com' -``` - -[Dufflepud](https://github.com/coracle-social/dufflepud) is an optional proxy server for NIP-05 lookups, zapper resolution, relay metadata, and link previews. Not required but helps bypass CORS. - ---- - -## Common Patterns - -### Login and publish a note - -```typescript -import {makeSecret} from '@welshman/util' -import {loginWithNip07, publishThunk, signer} from '@welshman/app' -import {Router} from '@welshman/router' -import {NOTE, makeEvent} from '@welshman/util' -import {Nip07Signer} from '@welshman/signer' - -// NIP-07 login -const nip07 = new Nip07Signer() -const pubkey = await nip07.getPubkey() -loginWithNip07(pubkey) - -// Publish with optimistic local update and 3s undo window -const thunk = publishThunk({ - event: makeEvent(NOTE, {content: 'Hello Nostr!'}), - relays: Router.get().FromUser().getUrls(), - delay: 3000, +// To the user's write relays (resolved via the Router): +const thunk = app.use(Thunks).publishToOutbox({ + event: makeEvent(NOTE, {content: "hi"}), + delay: 3000, // abortable soft-undo window (ms) }) -// Subscribe to per-relay status -thunk.subscribe($thunk => { - for (const [url, result] of Object.entries($thunk.results)) { - console.log(url, result.status, result.detail) +// To specific relays: +app.use(Thunks).publish({event, relays: ["wss://relay.example"]}) + +// A thunk is a Svelte store with per-relay status: +thunk.subscribe(t => console.log(t.results)) +thunk.abort() // effective only before `delay` elapses +await thunk.waitForCompletion() +thunk.getError() // string | undefined +app.use(Thunks).history // writable — optimistic log +app.use(Thunks).retry(thunk) + +// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps: +app.use(Thunks).publish({event, relays, recipient: theirPubkey}) +const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]}) + +// Proof of work (NIP-13): +app.use(Thunks).publish({event, relays, pow: 20}) +``` + +`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. + +## Requests & sync + +```typescript +import {Network, Sync} from "@welshman/app" +const net = app.use(Network) + +const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays}) +await net.request({filters, relays, autoClose: true}) + +// Outbox-model author load (resolves the author's write relays automatically): +const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]}) + +// Negentropy-aware reconciliation (falls back to request/publish when unsupported): +await app.use(Sync).pull({relays, filters: [{authors: [pk]}]}) +await app.use(Sync).push({relays, filters: [{authors: [pk]}]}) +``` + +## Routing & tags + +```typescript +import {Router, Tags} from "@welshman/app" +import {addMinimalFallbacks} from "@welshman/router" + +const router = app.use(Router) // per-app; NOT Router.get() +const relays = router.FromUser().policy(addMinimalFallbacks).limit(8).getUrls() +const hint = router.Event(event).getUrl() +// Scenes: FromUser(), FromPubkey(pk), FromRelays(urls), Event(e), EventRoots(e), Search() + +const tags = app.use(Tags) +const replyTags = tags.tagEventForReply(parentEvent) // also: tagPubkey, tagEvent, + // tagEventForComment/Quote/Reaction, tagZapSplit +app.use(Thunks).publishToOutbox({event: makeEvent(NOTE, {content: "ok", tags: replyTags})}) +``` + +Relay quality used by the router comes from `app.use(RelayStats).getQuality(url)` (0–1; 0 for blocked/error-prone relays). + +## Web of trust + +```typescript +const wot = app.use(Wot) +wot.graph.get() // Map (score = #roots following − #roots muting) +wot.max.get() // highest score +wot.follows(pk).get() // string[] +wot.network(pk).get() // follows-of-follows (minus direct follows) +wot.followers(pk).get() +wot.wotScore(myPk, theirPk).get() // number (or .$ for reactive) +``` + +## Feeds & search + +```typescript +import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds" +import {get} from "svelte/store" + +const controller = app.use(Feeds).makeFeedController({ + feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(1)), + onEvent: event => {/* render */}, +}) +await controller.load(50) // scopes (Self/Follows/Network/Followers) resolved via Wot + +const search = get(app.use(Searches).profileSearch) +const pubkeys = search.searchValues("alice") // also fires a NIP-50 network search; ranked by WoT +// also: app.use(Searches).topicSearch, relaySearch; createSearch(...) for custom indexes +``` + +## Plugin architecture (for extending) + +Three base classes in `plugins/base.ts`: + +- **`DerivedPlugin`** — collection derived from repository events (the repo is the single source of truth). Pass `{filters, eventToItem, getKey}`; implement `fetch`. This is the dominant pattern. +- **`LoadableMapPlugin`** — owns its own `Map`, lazily fetches over HTTP (e.g. `Relays`, `Handles`, `Zappers`). Implement `fetch`. +- **`MapPlugin`** — owns its own `Map`, no network (e.g. `RelayStats`, `Plaintext`). + +```typescript +import {DerivedPlugin, Network, type IApp} from "@welshman/app" +import {SOME_KIND, readSomething} from "@welshman/util" + +export class Somethings extends DerivedPlugin> { + constructor(app: IApp) { + super(app, {filters: [{kinds: [SOME_KIND]}], eventToItem: readSomething, getKey: i => i.event.pubkey}) } -}) - -// Soft-undo within delay window -setTimeout(() => thunk.controller.abort(), 1000) - -// Wait for all relays to finish (thunk.complete is a Deferred) -await thunk.complete -``` - -### Derive a reactive profile - -```typescript -import {deriveProfile, deriveProfileDisplay} from '@welshman/app' - -const targetPubkey = '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322' - -// Reactive store — loads the profile in the background on first subscribe -const profile = deriveProfile(targetPubkey) - -// Reactive display name with npub fallback -const name = deriveProfileDisplay(targetPubkey) - -// In Svelte -// $: displayName = $name -``` - -### Reply to an event - -```typescript -import {publishThunk, tagEventForReply, tagPubkey, signer} from '@welshman/app' -import {Router} from '@welshman/router' -import {NOTE, makeEvent} from '@welshman/util' -import type {TrustedEvent} from '@welshman/util' - -async function replyTo(parent: TrustedEvent, content: string) { - const tags = tagEventForReply(parent) - - return publishThunk({ - event: makeEvent(NOTE, {content, tags}), - relays: Router.get().PublishEvent(parent).getUrls(), - }) -} -``` - -### Send a NIP-59 gift-wrapped DM - -```typescript -import {sendWrapped} from '@welshman/app' -import {DIRECT_MESSAGE, makeEvent} from '@welshman/util' - -const mergedThunk = await sendWrapped({ - event: makeEvent(DIRECT_MESSAGE, {content: 'secret message'}), - recipients: [recipientPubkey], -}) - -// Monitor combined status -mergedThunk.subscribe($t => { - for (const [url, result] of Object.entries($t.results)) { - console.log(url, result.status) - } -}) -``` - -### Follow/unfollow - -```typescript -import {follow, unfollow} from '@welshman/app' - -// tag format: ["p", pubkey] or ["p", pubkey, relayHint, petname] -await follow(["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"]) -await unfollow("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322") -``` - -### Web of Trust filtering - -```typescript -import {deriveUserWotScore, getWotScore, wotGraph, maxWot} from '@welshman/app' -import {get} from 'svelte/store' - -// Filter a list of pubkeys to those with positive WoT score -const $graph = get(wotGraph) -const trusted = pubkeys.filter(pk => ($graph.get(pk) ?? 0) > 0) - -// Reactive score for a single user -const score = deriveUserWotScore(somePubkey) - -// Normalize by max score (0–1 range) -const $max = get(maxWot) -const normalized = ($graph.get(somePubkey) ?? 0) / ($max || 1) -``` - -### Load a feed of notes - -```typescript -import {makeFeedController, getPubkeysForScope} from '@welshman/app' -import {makeKindFeed} from '@welshman/feeds' -import {NOTE} from '@welshman/util' - -const abort = new AbortController() - -const ctrl = makeFeedController({ - feed: makeKindFeed(NOTE), - useWindowing: true, - signal: abort.signal, - onEvent: event => console.log(event), - onExhausted: () => console.log('no more events'), -}) - -ctrl.load(50) - -// cleanup -abort.abort() -``` - ---- - -## Integration Notes - -- `@welshman/app` **re-exports nothing** from `@welshman/net`, `@welshman/router`, etc. Import those directly when you need low-level primitives (`load`, `request`, `publish`, `Router` scenarios beyond `FromUser`). -- The `index.ts` bootstrap code runs on import and automatically wires `routerContext` (pubkey relays, relay quality, default/indexer/search relays) and hooks `Pool` to store incoming events in `repository`. **Import `@welshman/app` early in your app entry point** so this runs before any requests. The canonical side-effect import pattern is: - - ```typescript - // app entry point — must be first, before any @welshman/net or @welshman/router imports - import "@welshman/app" - - // Then optionally override defaults - import {routerContext} from "@welshman/router" - routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"] - ``` -- `repository` and `tracker` are singletons shared across the whole app. All subscriptions made through `@welshman/net` that pass through the pool will populate `repository` automatically. -- `Router` is imported from `@welshman/router` but `routerContext` is configured by `@welshman/app/index.ts`. Use `Router.get()` (not `new Router(...)`) to get the app-configured singleton. -- `deriveProfile`, `deriveFollowList`, etc. use `makeLoadItem` under the hood: they fire a network request on first subscribe if data is not already in the repository, then resolve immediately on subsequent subscribes. -- `userFollowList`, `userMuteList`, etc. are derived from `pubkey`. They automatically re-derive when the active session changes (multi-account support). - ---- - -## Using Welshman Stores Outside Svelte - -All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports. - -### React - -```typescript -import {useState, useEffect} from 'react' -import type {Readable, Writable} from 'svelte/store' - -// Returns the current store value; re-renders when it changes. -function useReadable(store: Readable): T { - const [value, setValue] = useState(() => { - // subscribe fires synchronously — capture the initial value then unsub immediately - let initial!: T - store.subscribe(v => { initial = v })() - return initial - }) - useEffect(() => store.subscribe(setValue), [store]) - return value + fetch = (pk: string, hints: string[] = []) => + this.app.use(Network).loadUsingOutbox(pk, {kinds: [SOME_KIND]}, hints) } -// Returns [currentValue, setter] — setter calls store.set directly. -function useWritable(store: Writable): [T, (value: T) => void] { - return [useReadable(store), store.set] -} +const things = app.use(Somethings) // lazily constructed + memoized ``` -Usage: +Caching/backoff for `load` come from `makeLoadItem` (`@welshman/store`); default staleness window is 1 hour; `forceLoad` bypasses it. -```tsx -import {userProfile, pubkey} from '@welshman/app' +## Policies & logging -function ProfileHeader() { - const profile = useReadable(userProfile) - const [currentPubkey, setPubkey] = useWritable(pubkey) +Side effects live in `AppPolicy`s (`(app) => Unsubscriber`), run at construction, cleaned up by `cleanup()`. - return
{profile?.name ?? currentPubkey}
-} -``` - -### SolidJS +- `defaultAppPolicies` = `[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyAuthUnlessBlocked]`. +- Auth builders: `makeAppPolicyAuth(shouldAuth)`, `appPolicyAuthAlways`, `appPolicyAuthNever`, `appPolicyAuthUnlessBlocked`. +- `makeAppPolicyLogger(onMessage)` forwards `LogMessage`s from the user's `LoggingSigner` (users created via `User.fromSigner`/`fromSession` are wrapped automatically). ```typescript -import {createSignal, onCleanup} from 'solid-js' -import type {Readable, Writable} from 'svelte/store' - -// Returns a SolidJS accessor (getter function); updates reactively. -function useReadable(store: Readable): () => T { - let initial!: T - store.subscribe(v => { initial = v })() // sync capture then unsubscribe - - const [value, setValue] = createSignal(initial) - onCleanup(store.subscribe(v => setValue(() => v))) - return value -} - -// Returns [accessor, setter]. -function useWritable(store: Writable): [() => T, (value: T) => void] { - return [useReadable(store), store.set] -} +import {App, defaultAppPolicies, makeAppPolicyLogger} from "@welshman/app" +const app = new App({user, policies: [...defaultAppPolicies, makeAppPolicyLogger(console.log)]}) ``` -Usage: +## Gotchas & tips -```tsx -import {userProfile} from '@welshman/app' +- **No globals.** Don't reach for importable `pubkey`/`deriveProfile`/`publishThunk`/`Router.get()` — they don't exist. Create an `App` and use `app.use(...)`. +- **`use()` is memoized per app.** `app.use(Profiles)` always returns the same instance for a given app. Cheap to call repeatedly. +- **`Projection` vs `Readable`.** Convenience accessors (`display`, `urls`, `wotScore`, …) return a `Projection` — use `.$` for the store, `.get()` for a snapshot. `one(key)` returns a plain `Readable` (and triggers a load on subscribe). +- **`get(key)` does not load; `one(key)`/`load(key)` do.** Use `get` for a pure cache read. +- **Most loads use the outbox model**, which needs the author's relay list. `loadUsingOutbox` (and therefore most `fetch` methods) first loads NIP-65 relays for the author. +- **`createApp` vs `new App`.** `createApp` installs default policies; `new App` installs none. In tests prefer `new App` (no background subscriptions) unless you need ingestion. +- **Call `cleanup()`** when discarding an app to close sockets and free the repository/tracker/wrap state. +- **The core class is `App`** (constructed by the `createApp` factory), the interface plugins depend on is `IApp`, and the config/options/policy types are `AppConfig`/`AppOptions`/`AppPolicy`. -function ProfileHeader() { - const profile = useReadable(userProfile) - return
{profile()?.name}
-} -``` +## Old API → new API -### Vue +| Old (global) | New (instance-based) | +|---|---| +| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` | +| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` | +| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` | +| `publishThunk({...})` | `app.use(Thunks).publish({...})` / `publishToOutbox({...})` | +| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag)` / `app.use(MuteLists).mutePublicly(tag)` | +| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` | +| `Router.get().FromUser()` | `app.use(Router).FromUser()` | +| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` | -```typescript -import {ref, onUnmounted} from 'vue' -import type {Readable, Writable} from 'svelte/store' +## Related skills -function useReadable(store: Readable) { - let initial!: T - store.subscribe(v => { initial = v })() - - const value = ref(initial) - const unsub = store.subscribe(v => { value.value = v as any }) - onUnmounted(unsub) - return value // use as a readonly ref -} -``` - -### Notes - -- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`. -- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`. -- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render. - ---- - -## Gotchas & Tips - -- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes. -- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing. -- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`. -- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded. -- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one. -- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead. -- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list. -- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer. -- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts. -- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup. +- `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on. +- `welshman-router` — relay-selection strategies behind `app.use(Router)`. +- `welshman-net` — request/publish/sockets behind `app.use(Network)`. +- `welshman-signer` — signers and login methods used by `User`/sessions. +- `welshman-feeds` — feed construction used by `app.use(Feeds)`.