From 96b0116c9ba39af937c876ef5291120a5941c8c5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 16 Jun 2026 10:32:59 -0700 Subject: [PATCH] Auto register client plugins --- packages/client/src/blockedRelayLists.ts | 11 +- packages/client/src/blossom.ts | 11 +- packages/client/src/client.ts | 216 ++++---------------- packages/client/src/clientData.ts | 8 +- packages/client/src/commands.ts | 113 +++++----- packages/client/src/createApp.ts | 143 +------------ packages/client/src/feeds.ts | 23 +-- packages/client/src/follows.ts | 11 +- packages/client/src/giftWraps.ts | 47 +---- packages/client/src/handles.ts | 19 +- packages/client/src/index.ts | 3 + packages/client/src/messagingRelayLists.ts | 11 +- packages/client/src/mutes.ts | 16 +- packages/client/src/networking.ts | 38 ++++ packages/client/src/pins.ts | 11 +- packages/client/src/policies.ts | 87 ++++++++ packages/client/src/profiles.ts | 13 +- packages/client/src/relayLists.ts | 38 ++-- packages/client/src/relayStats.ts | 93 +++------ packages/client/src/repositoryCollection.ts | 9 +- packages/client/src/router.ts | 69 ++----- packages/client/src/search.ts | 43 ++-- packages/client/src/searchRelayLists.ts | 11 +- packages/client/src/session.ts | 3 +- packages/client/src/stores.ts | 58 ++++++ packages/client/src/sync.ts | 26 ++- packages/client/src/tags.ts | 32 ++- packages/client/src/thunk.ts | 9 +- packages/client/src/topics.ts | 4 +- packages/client/src/wot.ts | 33 +-- packages/client/src/zappers.ts | 22 +- 31 files changed, 505 insertions(+), 726 deletions(-) create mode 100644 packages/client/src/networking.ts create mode 100644 packages/client/src/policies.ts create mode 100644 packages/client/src/stores.ts diff --git a/packages/client/src/blockedRelayLists.ts b/packages/client/src/blockedRelayLists.ts index 0b45ba6..0c50f86 100644 --- a/packages/client/src/blockedRelayLists.ts +++ b/packages/client/src/blockedRelayLists.ts @@ -1,8 +1,8 @@ import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.js" /** * Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model, @@ -10,10 +10,7 @@ import type {RelayLists} from "./relayLists.js" * blocked relays are never selected. */ export class BlockedRelayLists extends RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [BLOCKED_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -22,7 +19,7 @@ export class BlockedRelayLists extends RepositoryCollection getRelaysFromList(this.get(pubkey)) diff --git a/packages/client/src/blossom.ts b/packages/client/src/blossom.ts index 5545555..afb86cc 100644 --- a/packages/client/src/blossom.ts +++ b/packages/client/src/blossom.ts @@ -1,18 +1,15 @@ import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.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 RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [BLOSSOM_SERVERS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -21,6 +18,6 @@ export class BlossomServerLists extends RepositoryCollection string[] + getIndexerRelays?: () => string[] + getSearchRelays?: () => string[] +} export type ClientOptions = { user?: User + config?: ClientConfig getAdapter?: AdapterFactory socketPolicies?: SocketPolicy[] + policies?: ClientPolicy[] } -/** - * The narrow seam that data modules (plugins) depend on instead of the concrete - * `Client`. Because plugins reference only `ClientContext`, the client never - * imports them and they never create a dependency cycle back to the client — - * the dependency is strictly one-way (plugin -> context). - * - * `Client implements ClientContext`, so the two stay in sync by construction. - */ -export interface ClientContext { +export interface IClient { user?: User + config: ClientConfig + use: (Ctor: new (ctx: IClient) => T) => T + netContext: AdapterContext pool: Pool tracker: Tracker repository: Repository wrapManager: WrapManager - - // Net utilities, with this client's context baked in - request: (options: Omit) => ReturnType - publish: (options: Omit) => ReturnType - diff: (options: Omit) => ReturnType - pull: (options: Omit) => ReturnType - push: (options: Omit) => ReturnType - makeLoader: (options: Omit) => Loader - load: Loader - - // Store utilities, with this client's repository/tracker baked in - getEventsById: (options: Omit) => ReturnType - deriveEventsById: ( - options: Omit, - ) => ReturnType - deriveEvents: (options: Omit) => ReturnType - makeDeriveEvent: (options: Omit) => ReturnType - getEventsByIdByUrl: ( - options: Omit, - ) => ReturnType - deriveEventsByIdByUrl: ( - options: Omit, - ) => ReturnType - getEventsByIdForUrl: ( - options: Omit, - ) => ReturnType - deriveEventsByIdForUrl: ( - options: Omit, - ) => ReturnType - deriveItemsByKey: (options: Omit, "repository">) => Readable> - deriveIsDeleted: (event: TrustedEvent) => ReturnType } /** * The core of an application instance. Owns the primitives a single identity * needs (so data never bleeds across sessions) — a private repository, a socket - * pool, a tracker, a wrap manager — plus net/store helpers bound to them. - * - * Data modules are NOT fields on the client; they are composed separately (see - * `createApp`) and reach the client through the `ClientContext` seam. + * pool, a tracker, a wrap manager — and a `use` registry that resolves data + * modules (including net/store helpers) on demand. */ -export class Client implements ClientContext { +export class Client implements IClient { user?: User + config: ClientConfig + netContext: AdapterContext pool: Pool tracker: Tracker repository: Repository wrapManager: WrapManager - netContext: AdapterContext - load: Loader - ingestCleanup: Unsubscriber + + // Per-client singletons of data modules, keyed by constructor. Owned by the + // client (so it's GC'd with the client — no WeakMap needed), this is what + // `use` memoizes against. + private singletons = new Map() + private policyCleanups: Unsubscriber[] = [] constructor(options: ClientOptions = {}) { this.user = options.user + this.config = options.config ?? {} this.pool = new Pool({ makeSocket: (url: string) => { let socketPolicies = options.socketPolicies ?? defaultSocketPolicies @@ -148,89 +77,30 @@ export class Client implements ClientContext { repository: this.repository, getAdapter: options.getAdapter, } - this.load = this.makeLoader({ - delay: 200, - timeout: 3000, - threshold: 0.5, - }) - // Ingest every event received on any socket into this client'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. - this.ingestCleanup = this.pool.subscribe(socket => { - const onReceive = (message: RelayMessage) => this.ingest(message, socket.url) - - socket.on(SocketEvent.Receive, onReceive) - - return () => socket.off(SocketEvent.Receive, onReceive) - }) + // Apply policies last, once the primitives and `use` registry exist. They + // own all side effects; their cleanups run on `cleanup()`. + this.policyCleanups = (options.policies ?? []).map(policy => policy(this)) } - ingest = (message: RelayMessage, url: string) => { - if (!isRelayEvent(message)) return + // 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)), + // replacing constructor injection and letting cycles resolve lazily. + use = (Ctor: new (ctx: IClient) => T): T => { + let instance = this.singletons.get(Ctor) as T | undefined - const event = message[2] + if (!instance) { + this.singletons.set(Ctor, (instance = new Ctor(this))) + } - if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return - if (!netContext.isEventValid(event, url)) return - - this.tracker.track(event.id, url) - this.repository.publish(event) + return instance } cleanup() { - this.ingestCleanup() + this.policyCleanups.forEach(call) this.pool.clear() this.tracker.clear() this.repository.clear() this.wrapManager.clear() } - - // Net utilities, bound to this client's context - - request = (options: Omit) => - request({...options, context: this.netContext}) - - publish = (options: Omit) => - publish({...options, context: this.netContext}) - - diff = (options: Omit) => diff({...options, context: this.netContext}) - - pull = (options: Omit) => pull({...options, context: this.netContext}) - - push = (options: Omit) => push({...options, context: this.netContext}) - - makeLoader = (options: Omit): Loader => - makeLoader({...options, context: this.netContext}) - - // Store utilities, bound to this client's repository/tracker - - getEventsById = (options: Omit) => - getEventsById({...options, repository: this.repository}) - - deriveEventsById = (options: Omit) => - deriveEventsById({...options, repository: this.repository}) - - deriveEvents = (options: Omit) => - deriveEvents({...options, repository: this.repository}) - - makeDeriveEvent = (options: Omit) => - makeDeriveEvent({...options, repository: this.repository}) - - getEventsByIdByUrl = (options: Omit) => - getEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository}) - - deriveEventsByIdByUrl = (options: Omit) => - deriveEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository}) - - getEventsByIdForUrl = (options: Omit) => - getEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository}) - - deriveEventsByIdForUrl = (options: Omit) => - deriveEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository}) - - deriveItemsByKey = (options: Omit, "repository">) => - deriveItemsByKey({...options, repository: this.repository}) - - deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event) } diff --git a/packages/client/src/clientData.ts b/packages/client/src/clientData.ts index 63a73bc..d00c2db 100644 --- a/packages/client/src/clientData.ts +++ b/packages/client/src/clientData.ts @@ -3,7 +3,7 @@ import type {Readable, Unsubscriber} from "svelte/store" import type {Maybe} from "@welshman/lib" import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store" import type {MakeLoadItemOptions} from "@welshman/store" -import type {ClientContext} from "./client.js" +import type {IClient} from "./client.js" /** * Base class for a reactive, keyed collection of "local" (non-event) data — @@ -11,7 +11,7 @@ import type {ClientContext} from "./client.js" * repository. The collection owns its own map and is its own Svelte store: its * `subscribe` emits the underlying `Map`. * - * Subclasses reach the client through the `ClientContext` seam, never the + * Subclasses reach the client through the `IClient` seam, never the * concrete `Client`, so they never create a dependency cycle. */ export class ClientData { @@ -20,7 +20,7 @@ export class ClientData { protected itemSubscribers: ((key: string, value: Maybe) => void)[] = [] public derive: (key?: string, ...args: any[]) => Readable> - constructor(protected readonly ctx: ClientContext) { + constructor(protected readonly ctx: IClient) { this.derive = makeDeriveItem(this.index) } @@ -92,7 +92,7 @@ export abstract class LoadableData extends ClientData { abstract fetch(key: string, ...args: any[]): Promise - constructor(ctx: ClientContext, options: MakeLoadItemOptions = {}) { + constructor(ctx: IClient, options: MakeLoadItemOptions = {}) { super(ctx) // Subclasses implement `fetch` as an arrow field, whose initializer runs diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts index 8e99ba9..881fa2c 100644 --- a/packages/client/src/commands.ts +++ b/packages/client/src/commands.ts @@ -33,32 +33,18 @@ import { prep, } from "@welshman/util" import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util" -import {addMaximalFallbacks} from "./router.js" -import type {Router} from "./router.js" +import {addMaximalFallbacks, Router} from "./router.js" import {MergedThunk, publishThunk} from "./thunk.js" import type {ThunkOptions} from "./thunk.js" -import type {ClientContext} from "./client.js" +import type {IClient} from "./client.js" import type {User} from "./user.js" -import type {RelayLists} from "./relayLists.js" -import type {MessagingRelayLists} from "./messagingRelayLists.js" -import type {BlockedRelayLists} from "./blockedRelayLists.js" -import type {SearchRelayLists} from "./searchRelayLists.js" -import type {FollowLists} from "./follows.js" -import type {MuteLists} from "./mutes.js" -import type {PinLists} from "./pins.js" - -export type CommandsDeps = { - client: ClientContext - user: User - router: Router - relayLists: RelayLists - messagingRelayLists: MessagingRelayLists - blockedRelayLists: BlockedRelayLists - searchRelayLists: SearchRelayLists - followLists: FollowLists - muteLists: MuteLists - pinLists: PinLists -} +import {RelayLists} from "./relayLists.js" +import {MessagingRelayLists} from "./messagingRelayLists.js" +import {BlockedRelayLists} from "./blockedRelayLists.js" +import {SearchRelayLists} from "./searchRelayLists.js" +import {FollowLists} from "./follows.js" +import {MuteLists} from "./mutes.js" +import {PinLists} from "./pins.js" export type SendWrappedOptions = Omit< ThunkOptions, @@ -70,37 +56,54 @@ export type SendWrappedOptions = Omit< /** * The high-level "do an action" API: each method builds an event for the - * client's user and publishes it via a thunk. Replaces the old module of global - * functions; everything that used a global (current pubkey, signer, router, the - * user's lists) is now injected. + * client's user and publishes it via a thunk. Siblings (the user's lists, the + * router) are resolved lazily through `ctx.use`; the acting user is `ctx.user`. */ export class Commands { - readonly client: ClientContext - readonly user: User - readonly router: Router - readonly relayLists: RelayLists - readonly messagingRelayLists: MessagingRelayLists - readonly blockedRelayLists: BlockedRelayLists - readonly searchRelayLists: SearchRelayLists - readonly followLists: FollowLists - readonly muteLists: MuteLists - readonly pinLists: PinLists + constructor(readonly ctx: IClient) {} - constructor(deps: CommandsDeps) { - this.client = deps.client - this.user = deps.user - this.router = deps.router - this.relayLists = deps.relayLists - this.messagingRelayLists = deps.messagingRelayLists - this.blockedRelayLists = deps.blockedRelayLists - this.searchRelayLists = deps.searchRelayLists - this.followLists = deps.followLists - this.muteLists = deps.muteLists - this.pinLists = deps.pinLists + private get user(): User { + if (!this.ctx.user) { + throw new Error("Commands require a signed-in user") + } + + return this.ctx.user + } + + private get router() { + return this.ctx.use(Router) + } + + private get relayLists() { + return this.ctx.use(RelayLists) + } + + private get messagingRelayLists() { + return this.ctx.use(MessagingRelayLists) + } + + private get blockedRelayLists() { + return this.ctx.use(BlockedRelayLists) + } + + private get searchRelayLists() { + return this.ctx.use(SearchRelayLists) + } + + private get followLists() { + return this.ctx.use(FollowLists) + } + + private get muteLists() { + return this.ctx.use(MuteLists) + } + + private get pinLists() { + return this.ctx.use(PinLists) } private publish = (options: Omit) => - publishThunk({...options, client: this.client, user: this.user}) + publishThunk({...options, client: this.ctx, user: this.user}) private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls() @@ -149,7 +152,9 @@ export class Commands { setReadRelays = async (urls: string[]) => { const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) - const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1)) + 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] @@ -160,7 +165,9 @@ export class Commands { setWriteRelays = async (urls: string[]) => { const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) - const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1)) + 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] @@ -259,14 +266,16 @@ export class Commands { // NIP 02 unfollow = async (value: string) => { - const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) + const list = + (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) const event = await removeFromList(list, value).reconcile(this.encryptToSelf) return this.publish({event, relays: this.fromUser()}) } follow = async (tag: string[]) => { - const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) + const list = + (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf) return this.publish({event, relays: this.fromUser()}) diff --git a/packages/client/src/createApp.ts b/packages/client/src/createApp.ts index b0d9f6c..458bb30 100644 --- a/packages/client/src/createApp.ts +++ b/packages/client/src/createApp.ts @@ -1,141 +1,14 @@ import {Client} from "./client.js" import type {ClientOptions} from "./client.js" -import {Router} from "./router.js" -import type {RouterOptions} from "./router.js" -import {RelayStats} from "./relayStats.js" -import {RelayLists} from "./relayLists.js" -import {BlockedRelayLists} from "./blockedRelayLists.js" -import {Relays} from "./relays.js" -import {Plaintext} from "./plaintext.js" -import {Profiles} from "./profiles.js" -import {FollowLists} from "./follows.js" -import {MuteLists} from "./mutes.js" -import {PinLists} from "./pins.js" -import {BlossomServerLists} from "./blossom.js" -import {MessagingRelayLists} from "./messagingRelayLists.js" -import {SearchRelayLists} from "./searchRelayLists.js" -import {Handles} from "./handles.js" -import {Zappers} from "./zappers.js" -import {Topics} from "./topics.js" -import {Tags} from "./tags.js" -import {Wot} from "./wot.js" -import {Feeds} from "./feeds.js" -import {Searches} from "./search.js" -import {Sync} from "./sync.js" -import {GiftWraps} from "./giftWraps.js" -import {Commands} from "./commands.js" - -export type AppOptions = ClientOptions & { - dufflepudUrl?: string - // Whether to unwrap incoming NIP-59 gift wraps (DMs) for this client's user. - shouldUnwrap?: boolean - // The router's data dependencies are wired up below, so callers only supply - // the configuration knobs. - router?: Omit -} +import {defaultClientPolicies} from "./policies.js" /** - * Composes a default application instance: a `Client` plus the core data - * modules, wired together. This is where genuine domain cycles (Router <-> - * RelayLists, RelayStats <-> BlockedRelayLists) are broken — modules are given - * lazily-resolved closures that reach their siblings at call time, never at - * construction time. + * 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(Commands)`, etc. * - * Callers who want a different module set can ignore this helper and compose - * their own bag directly, or spread additional modules onto the result. + * For a bare client (no default side effects) construct `new Client(...)` + * directly, or pass your own `policies`. */ -export const createApp = (options: AppOptions = {}) => { - const client = new Client(options) - const relays = new Relays(client) - const plaintext = new Plaintext(client) - - // Declared up-front so the lazily-invoked closures below can reach them. None - // are called during construction, only at routing/scoring time. - let relayLists: RelayLists - let blockedRelayLists: BlockedRelayLists - - const relayStats = new RelayStats(client, { - isRelayBlocked: url => { - const pubkey = client.user?.pubkey - - return pubkey ? blockedRelayLists.getBlockedRelays(pubkey).includes(url) : false - }, - }) - - const router = new Router(client, { - ...options.router, - getRelaysForPubkey: (pubkey, mode) => relayLists.getRelaysForPubkey(pubkey, mode), - getRelayQuality: url => relayStats.getQuality(url), - }) - - relayLists = new RelayLists(client, router) - blockedRelayLists = new BlockedRelayLists(client, relayLists) - - const profiles = new Profiles(client, relayLists) - const followLists = new FollowLists(client, relayLists) - const muteLists = new MuteLists(client, relayLists, plaintext) - const pinLists = new PinLists(client, relayLists) - const blossomServerLists = new BlossomServerLists(client, relayLists) - const messagingRelayLists = new MessagingRelayLists(client, relayLists) - const searchRelayLists = new SearchRelayLists(client, relayLists) - const handles = new Handles(client, profiles, {dufflepudUrl: options.dufflepudUrl}) - const zappers = new Zappers(client, profiles, {dufflepudUrl: options.dufflepudUrl}) - const topics = new Topics(client) - const tags = new Tags(client, router, profiles) - const wot = new Wot(client, followLists, muteLists) - const feeds = new Feeds(client, wot) - const searches = new Searches(client, router, profiles, topics, relays, handles, wot) - const sync = new Sync(client, relays) - const giftWraps = new GiftWraps(client, {shouldUnwrap: options.shouldUnwrap}) - - // Commands act on behalf of a signed-in user, so they're only available when - // the client has one. - const commands = client.user - ? new Commands({ - client, - user: client.user, - router, - relayLists, - messagingRelayLists, - blockedRelayLists, - searchRelayLists, - followLists, - muteLists, - pinLists, - }) - : undefined - - return { - client, - router, - relays, - plaintext, - relayStats, - relayLists, - blockedRelayLists, - profiles, - followLists, - muteLists, - pinLists, - blossomServerLists, - messagingRelayLists, - searchRelayLists, - handles, - zappers, - topics, - tags, - wot, - feeds, - searches, - sync, - giftWraps, - commands, - cleanup: () => { - relayStats.cleanup() - giftWraps.cleanup() - client.cleanup() - }, - } -} - -export type App = ReturnType +export const createApp = (options: ClientOptions = {}) => + new Client({...options, policies: options.policies ?? defaultClientPolicies}) diff --git a/packages/client/src/feeds.ts b/packages/client/src/feeds.ts index 93c72f5..87b51b8 100644 --- a/packages/client/src/feeds.ts +++ b/packages/client/src/feeds.ts @@ -1,21 +1,18 @@ import {Scope, FeedController} from "@welshman/feeds" import type {FeedControllerOptions, Feed} from "@welshman/feeds" import type {AdapterContext} from "@welshman/net" -import type {ClientContext} from "./client.js" -import type {Wot} from "./wot.js" +import type {IClient} from "./client.js" +import {Wot} from "./wot.js" export type MakeFeedControllerOptions = Partial> & {feed: Feed} /** * Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is - * delegated to the injected `Wot`, and feeds fetch through THIS client's net - * context (pool + repository) rather than the global one. + * delegated to `Wot`, and feeds fetch through THIS client's net context (pool + + * repository) rather than the global one. */ export class Feeds { - constructor( - readonly ctx: ClientContext, - readonly wot: Wot, - ) {} + constructor(readonly ctx: IClient) {} getPubkeysForScope = (scope: Scope): string[] => { const $pubkey = this.ctx.user?.pubkey @@ -28,11 +25,11 @@ export class Feeds { case Scope.Self: return [$pubkey] case Scope.Follows: - return this.wot.getFollows($pubkey) + return this.ctx.use(Wot).getFollows($pubkey) case Scope.Network: - return this.wot.getNetwork($pubkey) + return this.ctx.use(Wot).getNetwork($pubkey) case Scope.Followers: - return this.wot.getFollowers($pubkey) + return this.ctx.use(Wot).getFollowers($pubkey) default: return [] } @@ -40,11 +37,11 @@ export class Feeds { getPubkeysForWOTRange = (min: number, max: number): string[] => { const pubkeys = [] - const $maxWot = this.wot.getMaxWot() ?? 0 + const $maxWot = this.ctx.use(Wot).getMaxWot() ?? 0 const thresholdMin = $maxWot * min const thresholdMax = $maxWot * max - for (const [tpk, score] of this.wot.getWotGraph().entries()) { + for (const [tpk, score] of this.ctx.use(Wot).getWotGraph().entries()) { if (score >= thresholdMin && score <= thresholdMax) { pubkeys.push(tpk) } diff --git a/packages/client/src/follows.ts b/packages/client/src/follows.ts index 54d2021..55e3d20 100644 --- a/packages/client/src/follows.ts +++ b/packages/client/src/follows.ts @@ -1,18 +1,15 @@ import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.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 RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [FOLLOWS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -21,6 +18,6 @@ export class FollowLists extends RepositoryCollection() queue: TaskQueue - cleanup: Unsubscriber - - constructor( - readonly ctx: ClientContext, - options: GiftWrapsOptions = {}, - ) { - this.shouldUnwrap.set(options.shouldUnwrap ?? false) + constructor(readonly ctx: IClient) { this.queue = new TaskQueue({ batchSize: 5, batchDelay: 30, @@ -53,23 +38,9 @@ export class GiftWraps { } }, }) - - // Process wraps already in the repository, then any that arrive later - for (const wrap of this.ctx.repository.query([{kinds: [WRAP]}])) { - this.enqueue(wrap) - } - - this.cleanup = on(this.ctx.repository, "update", ({added}: {added: TrustedEvent[]}) => { - for (const event of added) { - if (event.kind === WRAP) { - this.enqueue(event) - } - } - }) } enqueue = (wrap: TrustedEvent) => { - if (!get(this.shouldUnwrap)) return if (this.failedUnwraps.has(wrap.id)) return if (this.ctx.wrapManager.getRumor(wrap.id)) return diff --git a/packages/client/src/handles.ts b/packages/client/src/handles.ts index 7443abf..e4cde1c 100644 --- a/packages/client/src/handles.ts +++ b/packages/client/src/handles.ts @@ -2,8 +2,8 @@ import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import {deriveDeduplicated} from "@welshman/store" import {LoadableData} from "./clientData.js" -import type {ClientContext} from "./client.js" -import type {Profiles} from "./profiles.js" +import type {IClient} from "./client.js" +import {Profiles} from "./profiles.js" export type Handle = { nip05: string @@ -54,11 +54,7 @@ export const displayHandle = (handle: Handle) => displayNip05(handle.nip05) * handle. */ export class Handles extends LoadableData { - constructor( - ctx: ClientContext, - readonly profiles: Profiles, - readonly options: {dufflepudUrl?: string} = {}, - ) { + constructor(ctx: IClient) { super(ctx) } @@ -66,9 +62,10 @@ export class Handles extends LoadableData { const result = new Map() // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly - if (this.options.dufflepudUrl) { + if (this.ctx.config.dufflepudUrl) { const res: any = await tryCatch( - async () => await postJson(`${this.options.dufflepudUrl}/handle/info`, {handles: nip05s}), + async () => + await postJson(`${this.ctx.config.dufflepudUrl}/handle/info`, {handles: nip05s}), ) for (const {handle: nip05, info} of res?.data || []) { @@ -99,7 +96,7 @@ export class Handles extends LoadableData { }) loadForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await this.profiles.load(pubkey, relays) + const $profile = await this.ctx.use(Profiles).load(pubkey, relays) return $profile?.nip05 ? this.load($profile.nip05) : undefined } @@ -108,7 +105,7 @@ export class Handles extends LoadableData { this.loadForPubkey(pubkey, relays) return deriveDeduplicated( - [this.index, this.profiles.derive(pubkey, relays)], + [this.index, this.ctx.use(Profiles).derive(pubkey, relays)], ([$handlesByNip05, $profile]) => { if (!$profile?.nip05) return undefined diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0bda879..3ef9bd5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,7 @@ export * from "./client.js" +export * from "./policies.js" +export * from "./networking.js" +export * from "./stores.js" export * from "./clientData.js" export * from "./repositoryCollection.js" export * from "./user.js" diff --git a/packages/client/src/messagingRelayLists.ts b/packages/client/src/messagingRelayLists.ts index 3e08fd3..df0fb61 100644 --- a/packages/client/src/messagingRelayLists.ts +++ b/packages/client/src/messagingRelayLists.ts @@ -1,8 +1,8 @@ import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.js" /** * Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the @@ -10,10 +10,7 @@ import type {RelayLists} from "./relayLists.js" * collection. */ export class MessagingRelayLists extends RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [MESSAGING_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -22,6 +19,6 @@ export class MessagingRelayLists extends RepositoryCollection { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - readonly plaintext: Plaintext, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [MUTES]}], eventToItem: async (event: TrustedEvent) => { - const content = await plaintext.ensure(event) + const content = await ctx.use(Plaintext).ensure(event) // If this is our own mute list but it couldn't be decrypted yet because // no signer is available, don't cache a result with empty private tags — @@ -37,6 +33,6 @@ export class MuteLists extends RepositoryCollection { } fetch(pubkey: string, relayHints: string[] = []) { - return this.relayLists.makeOutboxLoader(MUTES)(pubkey, relayHints) + return this.ctx.use(RelayLists).makeOutboxLoader(MUTES)(pubkey, relayHints) } } diff --git a/packages/client/src/networking.ts b/packages/client/src/networking.ts new file mode 100644 index 0000000..4eb5b64 --- /dev/null +++ b/packages/client/src/networking.ts @@ -0,0 +1,38 @@ +import {request, publish, diff, pull, push, makeLoader} from "@welshman/net" +import type { + Loader, + LoaderOptions, + RequestOptions, + PublishOptions, + DiffOptions, + PullOptions, + PushOptions, +} from "@welshman/net" +import type {IClient} from "./client.js" + +/** + * Net utilities bound to the client's net context (its pool + repository). Reach + * it via `client.use(Networking)`; `load` is a shared, batched loader. + */ +export class Networking { + load: Loader + + constructor(readonly ctx: IClient) { + this.load = this.makeLoader({delay: 200, timeout: 3000, threshold: 0.5}) + } + + request = (options: Omit) => + request({...options, context: this.ctx.netContext}) + + publish = (options: Omit) => + publish({...options, context: this.ctx.netContext}) + + diff = (options: Omit) => diff({...options, context: this.ctx.netContext}) + + pull = (options: Omit) => pull({...options, context: this.ctx.netContext}) + + push = (options: Omit) => push({...options, context: this.ctx.netContext}) + + makeLoader = (options: Omit): Loader => + makeLoader({...options, context: this.ctx.netContext}) +} diff --git a/packages/client/src/pins.ts b/packages/client/src/pins.ts index c6c3e7c..187dee7 100644 --- a/packages/client/src/pins.ts +++ b/packages/client/src/pins.ts @@ -1,18 +1,15 @@ import {PINS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.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 RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [PINS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -21,6 +18,6 @@ export class PinLists extends RepositoryCollection> } fetch(pubkey: string, relayHints: string[] = []) { - return this.relayLists.makeOutboxLoader(PINS)(pubkey, relayHints) + return this.ctx.use(RelayLists).makeOutboxLoader(PINS)(pubkey, relayHints) } } diff --git a/packages/client/src/policies.ts b/packages/client/src/policies.ts new file mode 100644 index 0000000..123a93d --- /dev/null +++ b/packages/client/src/policies.ts @@ -0,0 +1,87 @@ +import type {Unsubscriber} from "svelte/store" +import {on} from "@welshman/lib" +import {WRAP, isDVMKind, isEphemeralKind} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {SocketEvent, netContext, isRelayEvent} from "@welshman/net" +import type {RelayMessage} from "@welshman/net" +import type {IClient} from "./client.js" +import {RelayStats} from "./relayStats.js" +import {GiftWraps} from "./giftWraps.js" + +/** + * A client policy is a side effect applied once per client 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 + +/** + * Ingests every event received on any socket into the client'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 => { + const onReceive = (message: RelayMessage) => { + if (!isRelayEvent(message)) return + + const event = message[2] + + if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return + if (!netContext.isEventValid(event, socket.url)) return + + client.tracker.track(event.id, socket.url) + client.repository.publish(event) + } + + socket.on(SocketEvent.Receive, onReceive) + + return () => socket.off(SocketEvent.Receive, onReceive) + }) + +/** + * Wires socket activity on the client's pool into the RelayStats store. + */ +export const clientPolicyRelayStats: ClientPolicy = client => { + const stats = client.use(RelayStats) + + return client.pool.subscribe(socket => { + socket.on(SocketEvent.Send, stats.onSocketSend) + socket.on(SocketEvent.Receive, stats.onSocketReceive) + socket.on(SocketEvent.Status, stats.onSocketStatus) + + return () => { + socket.off(SocketEvent.Send, stats.onSocketSend) + socket.off(SocketEvent.Receive, stats.onSocketReceive) + socket.off(SocketEvent.Status, stats.onSocketStatus) + } + }) +} + +/** + * Watches the client's repository for gift wraps (existing and incoming) and + * feeds them to the unwrap queue. + */ +export const clientPolicyGiftWraps: ClientPolicy = client => { + const giftWraps = client.use(GiftWraps) + + for (const wrap of client.repository.query([{kinds: [WRAP]}])) { + giftWraps.enqueue(wrap) + } + + return on(client.repository, "update", ({added}: {added: TrustedEvent[]}) => { + for (const event of added) { + if (event.kind === WRAP) { + giftWraps.enqueue(event) + } + } + }) +} + +export const defaultClientPolicies: ClientPolicy[] = [ + clientPolicyIngest, + clientPolicyRelayStats, + clientPolicyGiftWraps, +] diff --git a/packages/client/src/profiles.ts b/packages/client/src/profiles.ts index 58856e2..7bc4a7c 100644 --- a/packages/client/src/profiles.ts +++ b/packages/client/src/profiles.ts @@ -1,18 +1,15 @@ import {derived, readable} from "svelte/store" import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.js" /** * Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's - * write relays), so it depends on the relay-list collection. + * write relays), resolved through the relay-list collection at fetch time. */ export class Profiles extends RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [PROFILE]}], eventToItem: readProfile, @@ -21,7 +18,7 @@ export class Profiles extends RepositoryCollection diff --git a/packages/client/src/relayLists.ts b/packages/client/src/relayLists.ts index fbfe093..061192b 100644 --- a/packages/client/src/relayLists.ts +++ b/packages/client/src/relayLists.ts @@ -10,24 +10,18 @@ import { } from "@welshman/util" import type {Filter, TrustedEvent, PublishedList} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import {addMinimalFallbacks} from "./router.js" -import type {Router} from "./router.js" -import type {ClientContext} from "./client.js" +import {Router, addMinimalFallbacks} from "./router.js" +import {Networking} from "./networking.js" +import type {IClient} from "./client.js" /** - * NIP-65 relay lists, keyed by pubkey. This is the routing substrate every - * other outbox-model load depends on, so it also exposes `loadUsingOutbox` / - * `makeOutboxLoader` for other collections to build their fetchers on. - * - * It depends on a `Router`, and the `Router` depends (via injected functions) on - * this collection — a genuine domain cycle that `createApp` breaks by wiring the - * router's `getRelaysForPubkey` to a lazily-resolved closure. + * NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other + * outbox-model load depends on, so it also exposes `loadUsingOutbox` / + * `makeOutboxLoader` for other collections to build their fetchers on. It and the + * Router reference each other lazily via `ctx.use`, so the cycle never bites. */ export class RelayLists extends RepositoryCollection { - constructor( - ctx: ClientContext, - readonly router: Router, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -37,11 +31,12 @@ export class RelayLists extends RepositoryCollection { fetch(pubkey: string, relayHints: string[] = []) { const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}] + const router = this.ctx.use(Router) return Promise.all([ - this.ctx.load({filters, relays: this.router.FromRelays(relayHints).getUrls()}), - this.ctx.load({filters, relays: this.router.FromPubkey(pubkey).getUrls()}), - this.ctx.load({filters, relays: this.router.Index().getUrls()}), + this.ctx.use(Networking).load({filters, relays: router.FromRelays(relayHints).getUrls()}), + this.ctx.use(Networking).load({filters, relays: router.FromPubkey(pubkey).getUrls()}), + this.ctx.use(Networking).load({filters, relays: router.Index().getUrls()}), ]) } @@ -53,7 +48,8 @@ export class RelayLists extends RepositoryCollection { loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => { const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write) - const allRelays = this.router + const allRelays = this.ctx + .use(Router) .FromRelays(writeRelays) .policy(addMinimalFallbacks) .limit(8) @@ -64,7 +60,7 @@ export class RelayLists extends RepositoryCollection { } for (const relays of chunk(2, allRelays)) { - const events = await this.ctx.load({filters, relays}) + const events = await this.ctx.use(Networking).load({filters, relays}) if (events.length > 0) { return first(sortEventsDesc(events)) @@ -76,10 +72,10 @@ export class RelayLists extends RepositoryCollection { (kind: number, filter: Filter = {}) => async (pubkey: string, relayHints: string[] = []) => { const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] - const relays = this.router.FromRelays(relayHints).getUrls() + const relays = this.ctx.use(Router).FromRelays(relayHints).getUrls() await Promise.all([ - this.ctx.load({filters, relays}), + this.ctx.use(Networking).load({filters, relays}), this.loadUsingOutbox(kind, pubkey, filter), ]) } diff --git a/packages/client/src/relayStats.ts b/packages/client/src/relayStats.ts index d92c106..75b7559 100644 --- a/packages/client/src/relayStats.ts +++ b/packages/client/src/relayStats.ts @@ -1,10 +1,9 @@ -import type {Unsubscriber} from "svelte/store" import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib" import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util" -import {SocketStatus, SocketEvent} from "@welshman/net" -import type {Socket, ClientMessage, RelayMessage} from "@welshman/net" +import {SocketStatus} from "@welshman/net" +import type {ClientMessage, RelayMessage} from "@welshman/net" import {ClientData} from "./clientData.js" -import type {ClientContext} from "./client.js" +import {BlockedRelayLists} from "./blockedRelayLists.js" export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void] @@ -52,45 +51,22 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({ notice_count: 0, }) -export type RelayStatsOptions = { - // Allows a host app to zero out blocked relays once a blocked-relay-list - // module is composed in, without RelayStats depending on it. - isRelayBlocked?: (url: string) => boolean -} - /** - * Tracks per-relay connection statistics by listening to socket activity on the - * client's pool, and exposes a `getQuality` heuristic the router uses to rank - * relays. A "local" collection — its data isn't backed by the repository. + * 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`. */ export class RelayStats extends ClientData { - cleanup: Unsubscriber - - constructor( - ctx: ClientContext, - readonly statsOptions: RelayStatsOptions = {}, - ) { - super(ctx) - - this.cleanup = ctx.pool.subscribe(socket => { - socket.on(SocketEvent.Send, this.onSocketSend) - socket.on(SocketEvent.Receive, this.onSocketReceive) - socket.on(SocketEvent.Status, this.onSocketStatus) - - return () => { - socket.off(SocketEvent.Send, this.onSocketSend) - socket.off(SocketEvent.Receive, this.onSocketReceive) - socket.off(SocketEvent.Status, this.onSocketStatus) - } - }) - } - getQuality = (url: string) => { // Skip non-relays entirely if (!isRelayUrl(url)) return 0 - // Skip blocked relays (when a host app provides the check) - if (this.statsOptions.isRelayBlocked?.(url)) return 0 + // Skip relays the user has blocked + const pubkey = this.ctx.user?.pubkey + + if (pubkey && this.ctx.use(BlockedRelayLists).getBlockedRelays(pubkey).includes(url)) { + return 0 + } const stats = this.get(url) @@ -116,9 +92,7 @@ export class RelayStats extends ClientData { return 0.7 } - // Utilities for syncing stats from connections to relays - - private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => { + private update = batch(150, (batched: RelayStatsUpdate[]) => { for (const [url, updates] of groupBy(([url]) => url, batched)) { if (!url || !isRelayUrl(url)) { console.warn(`Attempted to update stats for an invalid relay url: ${url}`) @@ -136,9 +110,9 @@ export class RelayStats extends ClientData { } }) - private onSocketSend = ([verb]: ClientMessage, url: string) => { + onSocketSend = ([verb]: ClientMessage, url: string) => { if (verb === "REQ") { - this.updateRelayStats([ + this.update([ url, stats => { stats.request_count++ @@ -146,7 +120,7 @@ export class RelayStats extends ClientData { }, ]) } else if (verb === "EVENT") { - this.updateRelayStats([ + this.update([ url, stats => { stats.publish_count++ @@ -156,11 +130,11 @@ export class RelayStats extends ClientData { } } - private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { + onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { if (verb === "OK") { const [, ok] = extra - this.updateRelayStats([ + this.update([ url, stats => { if (ok) { @@ -171,14 +145,9 @@ export class RelayStats extends ClientData { }, ]) } else if (verb === "AUTH") { - this.updateRelayStats([ - url, - stats => { - stats.last_auth = now() - }, - ]) + this.update([url, stats => (stats.last_auth = now())]) } else if (verb === "EVENT") { - this.updateRelayStats([ + this.update([ url, stats => { stats.event_count++ @@ -186,25 +155,15 @@ export class RelayStats extends ClientData { }, ]) } else if (verb === "EOSE") { - this.updateRelayStats([ - url, - stats => { - stats.eose_count++ - }, - ]) + this.update([url, stats => stats.eose_count++]) } else if (verb === "NOTICE") { - this.updateRelayStats([ - url, - stats => { - stats.notice_count++ - }, - ]) + this.update([url, stats => stats.notice_count++]) } } - private onSocketStatus = (status: string, url: string) => { + onSocketStatus = (status: string, url: string) => { if (status === SocketStatus.Open) { - this.updateRelayStats([ + this.update([ url, stats => { stats.last_open = now() @@ -214,7 +173,7 @@ export class RelayStats extends ClientData { } if (status === SocketStatus.Closed) { - this.updateRelayStats([ + this.update([ url, stats => { stats.last_close = now() @@ -224,7 +183,7 @@ export class RelayStats extends ClientData { } if (status === SocketStatus.Error) { - this.updateRelayStats([ + this.update([ url, stats => { stats.last_error = now() diff --git a/packages/client/src/repositoryCollection.ts b/packages/client/src/repositoryCollection.ts index 518c0b3..961d2dc 100644 --- a/packages/client/src/repositoryCollection.ts +++ b/packages/client/src/repositoryCollection.ts @@ -3,7 +3,8 @@ import type {Maybe} from "@welshman/lib" import type {Filter} from "@welshman/util" import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store" import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store" -import type {ClientContext} from "./client.js" +import type {IClient} from "./client.js" +import {Stores} from "./stores.js" export type RepositoryCollectionOptions = { filters: Filter[] @@ -19,7 +20,7 @@ export type RepositoryCollectionOptions = { * `fetch` (how to load an item by key from the network) and pass the * filters/decoder via `super`. * - * Like `ClientData`, subclasses depend only on the `ClientContext` seam. + * Like `ClientData`, subclasses depend only on the `IClient` seam. */ export abstract class RepositoryCollection { byKey: Readable> @@ -40,12 +41,12 @@ export abstract class RepositoryCollection { abstract fetch(key: string, ...args: any[]): Promise constructor( - protected readonly ctx: ClientContext, + protected readonly ctx: IClient, options: RepositoryCollectionOptions, ) { const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args) - this.byKey = ctx.deriveItemsByKey({ + this.byKey = ctx.use(Stores).deriveItemsByKey({ filters: options.filters, eventToItem: options.eventToItem, getKey: options.getKey, diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 8b52770..22ca473 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -11,55 +11,15 @@ import { RelayMode, } from "@welshman/util" import type {TrustedEvent, Filter} from "@welshman/util" -import type {ClientContext} from "./client.js" +import type {IClient} from "./client.js" +import {RelayLists} from "./relayLists.js" +import {RelayStats} from "./relayStats.js" export type RelaysAndFilters = { relays: string[] filters: Filter[] } -export type RouterOptions = { - /** - * Retrieves default relays, for use as fallbacks when no other relays can be selected. - * @returns An array of relay URLs as strings. - */ - getDefaultRelays?: () => string[] - - /** - * Retrieves relays that index profiles and relay selections. - * @returns An array of relay URLs as strings. - */ - getIndexerRelays?: () => string[] - - /** - * Retrieves relays likely to support NIP-50 search. - * @returns An array of relay URLs as strings. - */ - getSearchRelays?: () => string[] - - /** - * Retrieves the limit setting, which is the maximum number of relays that should be - * returned from getUrls and getSelections. - * @returns The limit setting as a number. - */ - getLimit?: () => number - - /** - * Retrieves a pubkey's relays for a given mode. Injected (rather than read off - * a relay-list module directly) so the router has no dependency on its sibling - * data modules. See `createApp`. - * @returns An array of relay URLs as strings. - */ - getRelaysForPubkey?: (pubkey: string, mode?: RelayMode) => string[] - - /** - * Scores a relay url for ranking (higher is better). Injected so the router - * doesn't depend on the relay-stats module. - * @returns A quality score, typically between 0 and 1. - */ - getRelayQuality?: (url: string) => number -} - export type Selection = { weight: number relays: string[] @@ -83,15 +43,12 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou // Router class export class Router { - constructor( - readonly ctx: ClientContext, - readonly options: RouterOptions, - ) {} + constructor(readonly ctx: IClient) {} - // Utilities derived from options + // Utilities derived from the relay-list collection and client config getRelaysForPubkey = (pubkey: string, mode?: RelayMode) => - this.options.getRelaysForPubkey?.(pubkey, mode) || [] + this.ctx.use(RelayLists).getRelaysForPubkey(pubkey, mode) getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) => pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode)) @@ -113,11 +70,11 @@ export class Router { FromRelays = (relays: string[]) => this.scenario([makeSelection(relays)]) - Search = () => this.FromRelays(this.options.getSearchRelays?.() || []) + Search = () => this.FromRelays(this.ctx.config.getSearchRelays?.() || []) - Index = () => this.FromRelays(this.options.getIndexerRelays?.() || []) + Index = () => this.FromRelays(this.ctx.config.getIndexerRelays?.() || []) - Default = () => this.FromRelays(this.options.getDefaultRelays?.() || []) + Default = () => this.FromRelays(this.ctx.config.getDefaultRelays?.() || []) ForUser = () => this.FromRelays(this.getRelaysForUser(RelayMode.Read)) @@ -251,9 +208,9 @@ export class RouterScenario { weight = (scale: number) => this.update(selection => ({...selection, weight: selection.weight * scale})) - getPolicy = () => this.options.policy || addNoFallbacks + getPolicy = () => this.options.policy ?? addNoFallbacks - getLimit = () => this.options.limit || this.router.options.getLimit?.() || 3 + getLimit = () => this.options.limit ?? 3 getUrls = () => { const limit = this.getLimit() @@ -274,7 +231,7 @@ export class RouterScenario { const scoreRelay = (relay: string) => { const weight = relayWeights.get(relay)! - const quality = this.router.options.getRelayQuality?.(relay) ?? 1 + const quality = this.router.ctx.use(RelayStats).getQuality(relay) // Log the weight, since it's a straight count which ends up over-weighting hubs. // Also add some random noise so that we'll occasionally pick lower quality/less @@ -288,7 +245,7 @@ export class RouterScenario { ) const fallbacksNeeded = fallbackPolicy(relays.length, limit) - const allFallbackRelays: string[] = this.router.options.getDefaultRelays?.() || [] + const allFallbackRelays: string[] = this.router.ctx.config.getDefaultRelays?.() || [] const fallbackRelays = shuffle(allFallbackRelays).slice(0, fallbacksNeeded) for (const fallbackRelay of fallbackRelays) { diff --git a/packages/client/src/search.ts b/packages/client/src/search.ts index 686336a..952173b 100644 --- a/packages/client/src/search.ts +++ b/packages/client/src/search.ts @@ -7,13 +7,15 @@ import {dec, inc, sortBy} from "@welshman/lib" import {PROFILE} from "@welshman/util" import type {PublishedProfile, RelayProfile} from "@welshman/util" import {throttled, deriveItems} from "@welshman/store" -import type {ClientContext} from "./client.js" -import type {Router} from "./router.js" -import type {Profiles} from "./profiles.js" -import type {Topics, Topic} from "./topics.js" -import type {Relays} from "./relays.js" -import type {Handles} from "./handles.js" -import type {Wot} from "./wot.js" +import type {IClient} from "./client.js" +import {Networking} from "./networking.js" +import {Router} from "./router.js" +import {Profiles} from "./profiles.js" +import {Topics} from "./topics.js" +import type {Topic} from "./topics.js" +import {Relays} from "./relays.js" +import {Handles} from "./handles.js" +import {Wot} from "./wot.js" export type SearchOptions = { getValue: (item: T) => V @@ -65,22 +67,13 @@ export class Searches { topicSearch: Readable> relaySearch: Readable> - constructor( - readonly ctx: ClientContext, - readonly router: Router, - readonly profiles: Profiles, - readonly topics: Topics, - readonly relays: Relays, - readonly handles: Handles, - readonly wot: Wot, - ) { + constructor(readonly ctx: IClient) { this.profileSearch = derived( - [throttled(800, this.profiles.all), throttled(800, this.handles)], + [throttled(800, this.ctx.use(Profiles).all), throttled(800, this.ctx.use(Handles))], ([$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 + const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey return isNip05Valid ? p : {...p, nip05: ""} }) @@ -89,9 +82,9 @@ export class Searches { onSearch: this.searchProfiles, getValue: (profile: PublishedProfile) => profile.event.pubkey, sortFn: ({score = 1, item}) => { - const wotScore = this.wot.getWotGraph().get(item.event.pubkey) || 0 + const wotScore = this.ctx.use(Wot).getWotGraph().get(item.event.pubkey) || 0 - return dec(score) * inc(wotScore / (this.wot.getMaxWot() || 1)) + return dec(score) * inc(wotScore / (this.ctx.use(Wot).getMaxWot() || 1)) }, fuseOptions: { keys: [ @@ -107,14 +100,14 @@ export class Searches { }, ) - this.topicSearch = derived(this.topics.all, $topics => + this.topicSearch = derived(this.ctx.use(Topics).all, $topics => createSearch($topics, { getValue: (topic: Topic) => topic.name, fuseOptions: {keys: ["name"]}, }), ) - this.relaySearch = derived(deriveItems(this.relays), $relays => + this.relaySearch = derived(deriveItems(this.ctx.use(Relays)), $relays => createSearch($relays, { getValue: (relay: RelayProfile) => relay.url, fuseOptions: { @@ -126,9 +119,9 @@ export class Searches { searchProfiles = debounce(500, (search: string) => { if (search.length > 2) { - this.ctx.load({ + this.ctx.use(Networking).load({ filters: [{kinds: [PROFILE], search}], - relays: this.router.Search().getUrls(), + relays: this.ctx.use(Router).Search().getUrls(), }) } }) diff --git a/packages/client/src/searchRelayLists.ts b/packages/client/src/searchRelayLists.ts index d4c4f15..90e52fa 100644 --- a/packages/client/src/searchRelayLists.ts +++ b/packages/client/src/searchRelayLists.ts @@ -1,8 +1,8 @@ import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {RepositoryCollection} from "./repositoryCollection.js" -import type {ClientContext} from "./client.js" -import type {RelayLists} from "./relayLists.js" +import {RelayLists} from "./relayLists.js" +import type {IClient} from "./client.js" /** * NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the @@ -10,10 +10,7 @@ import type {RelayLists} from "./relayLists.js" * collection. */ export class SearchRelayLists extends RepositoryCollection> { - constructor( - ctx: ClientContext, - readonly relayLists: RelayLists, - ) { + constructor(ctx: IClient) { super(ctx, { filters: [{kinds: [SEARCH_RELAYS]}], eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), @@ -22,6 +19,6 @@ export class SearchRelayLists extends RepositoryCollection { 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 PomadeClient(session.clientOptions))) + if (isPomadeSession(session)) + return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions))) if (isNip46Session(session)) { const { secret: clientSecret, diff --git a/packages/client/src/stores.ts b/packages/client/src/stores.ts new file mode 100644 index 0000000..4aa8ba1 --- /dev/null +++ b/packages/client/src/stores.ts @@ -0,0 +1,58 @@ +import { + getEventsById, + deriveEventsById, + deriveEvents, + makeDeriveEvent, + getEventsByIdByUrl, + deriveEventsByIdByUrl, + getEventsByIdForUrl, + deriveEventsByIdForUrl, + deriveItemsByKey, + deriveIsDeleted, +} from "@welshman/store" +import type { + EventsByIdOptions, + EventOptions, + EventsByIdByUrlOptions, + EventsByIdForUrlOptions, + ItemsByKeyOptions, +} from "@welshman/store" +import type {TrustedEvent} from "@welshman/util" +import type {IClient} from "./client.js" + +/** + * Store/derivation utilities bound to the client's repository and tracker. Reach + * it via `client.use(Stores)`. + */ +export class Stores { + constructor(readonly ctx: IClient) {} + + getEventsById = (options: Omit) => + getEventsById({...options, repository: this.ctx.repository}) + + deriveEventsById = (options: Omit) => + deriveEventsById({...options, repository: this.ctx.repository}) + + deriveEvents = (options: Omit) => + deriveEvents({...options, repository: this.ctx.repository}) + + makeDeriveEvent = (options: Omit) => + makeDeriveEvent({...options, repository: this.ctx.repository}) + + getEventsByIdByUrl = (options: Omit) => + getEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + + deriveEventsByIdByUrl = (options: Omit) => + deriveEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + + getEventsByIdForUrl = (options: Omit) => + getEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + + deriveEventsByIdForUrl = (options: Omit) => + deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository}) + + deriveItemsByKey = (options: Omit, "repository">) => + deriveItemsByKey({...options, repository: this.ctx.repository}) + + deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event) +} diff --git a/packages/client/src/sync.ts b/packages/client/src/sync.ts index bced0a2..48eac4a 100644 --- a/packages/client/src/sync.ts +++ b/packages/client/src/sync.ts @@ -1,7 +1,8 @@ import {isSignedEvent} from "@welshman/util" import type {Filter, SignedEvent} from "@welshman/util" -import type {ClientContext} from "./client.js" -import type {Relays} from "./relays.js" +import type {IClient} from "./client.js" +import {Networking} from "./networking.js" +import {Relays} from "./relays.js" export type AppSyncOpts = { relays: string[] @@ -12,19 +13,16 @@ export type AppSyncOpts = { * Negentropy-aware sync. Pulls/pushes events between the local repository and a * set of relays, using NIP-77 reconciliation where the relay supports it and * falling back to plain request/publish otherwise. Reads NIP-11 relay profiles - * from the injected `Relays` collection to detect negentropy support. + * from the `Relays` collection to detect negentropy support. */ export class Sync { - constructor( - readonly ctx: ClientContext, - readonly relays: Relays, - ) {} + constructor(readonly ctx: IClient) {} query = (filters: Filter[]) => this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) hasNegentropy = (url: string) => { - const relay = this.relays.get(url) + const relay = this.ctx.use(Relays).get(url) if (relay?.negentropy) return true if (relay?.supported_nips?.includes?.("77")) return true @@ -34,27 +32,27 @@ export class Sync { } pull = async ({relays, filters}: AppSyncOpts) => { + const net = this.ctx.use(Networking) const events = this.query(filters).filter(isSignedEvent) await Promise.all( relays.map(async relay => { await (this.hasNegentropy(relay) - ? this.ctx.pull({filters, events, relays: [relay]}) - : this.ctx.request({filters, relays: [relay], autoClose: true})) + ? net.pull({filters, events, relays: [relay]}) + : net.request({filters, relays: [relay], autoClose: true})) }), ) } push = async ({relays, filters}: AppSyncOpts) => { + const net = this.ctx.use(Networking) const events = this.query(filters).filter(isSignedEvent) await Promise.all( relays.map(async relay => { await (this.hasNegentropy(relay) - ? this.ctx.push({filters, events, relays: [relay]}) - : Promise.all( - events.map((event: SignedEvent) => this.ctx.publish({event, relays: [relay]})), - )) + ? net.push({filters, events, relays: [relay]}) + : Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]})))) }), ) } diff --git a/packages/client/src/tags.ts b/packages/client/src/tags.ts index 596c917..fb12ba2 100644 --- a/packages/client/src/tags.ts +++ b/packages/client/src/tags.ts @@ -8,9 +8,9 @@ import { isShareableRelayUrl, } from "@welshman/util" import type {TrustedEvent} from "@welshman/util" -import type {Router} from "./router.js" -import type {Profiles} from "./profiles.js" -import type {ClientContext} from "./client.js" +import {Router} from "./router.js" +import {Profiles} from "./profiles.js" +import type {IClient} from "./client.js" /** * Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router @@ -18,29 +18,25 @@ import type {ClientContext} from "./client.js" * user to avoid self-tagging. */ export class Tags { - constructor( - readonly ctx: ClientContext, - readonly router: Router, - readonly profiles: Profiles, - ) {} + constructor(readonly ctx: IClient) {} tagZapSplit = (pubkey: string, split = 1) => [ "zap", pubkey, - this.router.FromPubkey(pubkey).getUrl() || "", + this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "", String(split), ] tagPubkey = (pubkey: string) => [ "p", pubkey, - this.router.FromPubkey(pubkey).getUrl() || "", - this.profiles.display(pubkey), + this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "", + this.ctx.use(Profiles).display(pubkey), ] tagEvent = (event: TrustedEvent, url = "", mark = "") => { if (!url) { - url = this.router.Event(event).getUrl() || "" + url = this.ctx.use(Router).Event(event).getUrl() || "" } const tags = [["e", event.id, url, mark, event.pubkey]] @@ -58,7 +54,7 @@ export class Tags { ).map(pubkey => this.tagPubkey(pubkey)) tagEventForQuote = (event: TrustedEvent, relay?: string) => { - const hint = relay || this.router.Event(event).getUrl() || "" + const hint = relay || this.ctx.use(Router).Event(event).getUrl() || "" return ["q", event.id, hint, event.pubkey] } @@ -68,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.router.Event(event).getUrl() || "" + const hint = relay || this.ctx.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.router.EventRoots(event).getUrl() + : this.ctx.use(Router).EventRoots(event).getUrl() tags.push([k, id, hint || "", "root", pubkey]) } @@ -91,8 +87,8 @@ export class Tags { } tagEventForComment = (event: TrustedEvent, relay?: string) => { - const pubkeyHint = this.router.FromPubkey(event.pubkey).getUrl() || "" - const eventHint = relay || this.router.Event(event).getUrl() || "" + const pubkeyHint = this.ctx.use(Router).FromPubkey(event.pubkey).getUrl() || "" + const eventHint = relay || this.ctx.use(Router).Event(event).getUrl() || "" const address = getAddress(event) const seenRoots = new Set() const tags: string[][] = [] @@ -126,7 +122,7 @@ export class Tags { } tagEventForReaction = (event: TrustedEvent, relay?: string) => { - const hint = relay || this.router.Event(event).getUrl() || "" + const hint = relay || this.ctx.use(Router).Event(event).getUrl() || "" const tags: string[][] = [] // Mention the event's author diff --git a/packages/client/src/thunk.ts b/packages/client/src/thunk.ts index b72824b..7aa8966 100644 --- a/packages/client/src/thunk.ts +++ b/packages/client/src/thunk.ts @@ -13,14 +13,15 @@ import { } from "@welshman/util" import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net" import {Nip01Signer, Nip59} from "@welshman/signer" -import type {ClientContext} from './client.js' -import type {User} from './user.js' +import type {IClient} from "./client.js" +import {Networking} from "./networking.js" +import type {User} from "./user.js" export type ThunkOptions = Override< PublishOptions, { user: User - client: ClientContext + client: IClient event: EventTemplate recipient?: string delay?: number @@ -111,7 +112,7 @@ export class Thunk { } // Send it off - await this.options.client.publish({ + await this.options.client.use(Networking).publish({ ...this.options, event, onSuccess: (result: PublishResult) => { diff --git a/packages/client/src/topics.ts b/packages/client/src/topics.ts index 9b47628..5d6fab4 100644 --- a/packages/client/src/topics.ts +++ b/packages/client/src/topics.ts @@ -3,7 +3,7 @@ import type {Readable} from "svelte/store" import {on} from "@welshman/lib" import {getTopicTagValues} from "@welshman/util" import {deriveItems} from "@welshman/store" -import type {ClientContext} from "./client.js" +import type {IClient} from "./client.js" export type Topic = { name: string @@ -18,7 +18,7 @@ export class Topics { byName: Readable> all: Readable - constructor(readonly ctx: ClientContext) { + constructor(readonly ctx: IClient) { const topicsByName = new Map() const addTopic = (name: string) => { diff --git a/packages/client/src/wot.ts b/packages/client/src/wot.ts index 32e3040..5571643 100644 --- a/packages/client/src/wot.ts +++ b/packages/client/src/wot.ts @@ -3,9 +3,9 @@ import type {Readable, 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 type {ClientContext} from "./client.js" -import type {FollowLists} from "./follows.js" -import type {MuteLists} from "./mutes.js" +import type {IClient} from "./client.js" +import {FollowLists} from "./follows.js" +import {MuteLists} from "./mutes.js" /** * Web-of-trust scoring derived from follow and mute lists. The trust graph is @@ -23,12 +23,11 @@ export class Wot { private getWotGraphStore: () => Map private getMaxWotStore: () => number | undefined - constructor( - readonly ctx: ClientContext, - readonly followLists: FollowLists, - readonly muteLists: MuteLists, - ) { - this.followersByPubkey = derived(throttled(1000, this.followLists.all), lists => { + constructor(readonly ctx: IClient) { + const followLists = this.ctx.use(FollowLists) + const muteLists = this.ctx.use(MuteLists) + + this.followersByPubkey = derived(throttled(1000, followLists.all), lists => { const $followersByPubkey = new Map>() for (const list of lists) { @@ -40,7 +39,7 @@ export class Wot { return $followersByPubkey }) - this.mutersByPubkey = derived(throttled(1000, this.muteLists.all), lists => { + this.mutersByPubkey = derived(throttled(1000, muteLists.all), lists => { const $mutersByPubkey = new Map>() for (const list of lists) { @@ -61,13 +60,15 @@ export class Wot { this.getWotGraphStore = getter(this.wotGraph) this.getMaxWotStore = getter(this.maxWot) - this.followLists.subscribe(this.buildGraph) - this.muteLists.subscribe(this.buildGraph) + followLists.subscribe(this.buildGraph) + muteLists.subscribe(this.buildGraph) } - getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(this.followLists.get(pubkey))) + getFollows = (pubkey: string) => + getPubkeyTagValues(getListTags(this.ctx.use(FollowLists).get(pubkey))) - getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(this.muteLists.get(pubkey))) + getMutes = (pubkey: string) => + getPubkeyTagValues(getListTags(this.ctx.use(MuteLists).get(pubkey))) getNetwork = (pubkey: string) => { const pubkeys = new Set(this.getFollows(pubkey)) @@ -105,7 +106,9 @@ export class Wot { buildGraph = throttle(1000, () => { const $pubkey = this.ctx.user?.pubkey const $graph = new Map() - const $follows = $pubkey ? this.getFollows($pubkey) : Array.from(this.followLists.keys()) + const $follows = $pubkey + ? this.getFollows($pubkey) + : Array.from(this.ctx.use(FollowLists).keys()) for (const follow of $follows) { for (const pubkey of this.getFollows(follow)) { diff --git a/packages/client/src/zappers.ts b/packages/client/src/zappers.ts index 991835e..160bcee 100644 --- a/packages/client/src/zappers.ts +++ b/packages/client/src/zappers.ts @@ -12,8 +12,8 @@ import {getTagValues, zapFromEvent} from "@welshman/util" import type {Zapper, Zap, TrustedEvent} from "@welshman/util" import {deriveDeduplicated} from "@welshman/store" import {LoadableData} from "./clientData.js" -import type {ClientContext} from "./client.js" -import type {Profiles} from "./profiles.js" +import type {IClient} from "./client.js" +import {Profiles} from "./profiles.js" /** * Lightning zapper info, keyed by lnurl. A "local" loadable collection: items @@ -22,11 +22,7 @@ import type {Profiles} from "./profiles.js" * profiles collection to resolve a pubkey's lnurl. */ export class Zappers extends LoadableData { - constructor( - ctx: ClientContext, - readonly profiles: Profiles, - readonly options: {dufflepudUrl?: string} = {}, - ) { + constructor(ctx: IClient) { super(ctx) } @@ -45,11 +41,11 @@ export class Zappers extends LoadableData { } // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly - if (this.options.dufflepudUrl) { + if (this.ctx.config.dufflepudUrl) { const hexUrls = valid.map(bech32ToHex) const res: any = await tryCatch( async () => - await postJson(`${this.options.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}), + await postJson(`${this.ctx.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}), ) for (const {lnurl, info} of res?.data || []) { @@ -71,7 +67,7 @@ export class Zappers extends LoadableData { }) loadForPubkey = async (pubkey: string, relays: string[] = []) => { - const $profile = await this.profiles.load(pubkey, relays) + const $profile = await this.ctx.use(Profiles).load(pubkey, relays) return $profile?.lnurl ? this.load($profile.lnurl) : undefined } @@ -80,7 +76,7 @@ export class Zappers extends LoadableData { this.loadForPubkey(pubkey, relays) return deriveDeduplicated( - [this.index, this.profiles.derive(pubkey, relays)], + [this.index, this.ctx.use(Profiles).derive(pubkey, relays)], ([$zappersByLnurl, $profile]) => $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined, ) @@ -90,7 +86,7 @@ export class Zappers extends LoadableData { const pubkeys = getTagValues("zap", event.tags) if (pubkeys.length > 0) { - const profiles = await Promise.all(pubkeys.map(pubkey => this.profiles.load(pubkey))) + const profiles = await Promise.all(pubkeys.map(pubkey => this.ctx.use(Profiles).load(pubkey))) const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl)) if (lnurls.length > 0) { @@ -98,7 +94,7 @@ export class Zappers extends LoadableData { } } - const profile = await this.profiles.load(event.pubkey) + const profile = await this.ctx.use(Profiles).load(event.pubkey) return removeUndefined([profile?.lnurl]) }