Auto register client plugins

This commit is contained in:
Jon Staab
2026-06-16 10:32:59 -07:00
parent ea9cc0bf26
commit 96b0116c9b
31 changed files with 505 additions and 726 deletions
+4 -7
View File
@@ -1,8 +1,8 @@
import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util" import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model, * 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. * blocked relays are never selected.
*/ */
export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof readList>> { export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [BLOCKED_RELAYS]}], filters: [{kinds: [BLOCKED_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -22,7 +19,7 @@ export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof re
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints)
} }
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey)) getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
+4 -7
View File
@@ -1,18 +1,15 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util" import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox * 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. * model (the author's write relays), so it depends on the relay-list collection.
*/ */
export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof readList>> { export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [BLOSSOM_SERVERS]}], filters: [{kinds: [BLOSSOM_SERVERS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -21,6 +18,6 @@ export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof r
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints)
} }
} }
+43 -173
View File
@@ -1,131 +1,60 @@
import type {Readable, Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {isDVMKind, isEphemeralKind} from "@welshman/util" import {call} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import {Pool, Socket, Tracker, Repository, WrapManager, defaultSocketPolicies} from "@welshman/net"
import { import type {AdapterContext, AdapterFactory, SocketPolicy} from "@welshman/net"
Pool,
Socket,
SocketEvent,
Tracker,
Repository,
WrapManager,
defaultSocketPolicies,
netContext,
isRelayEvent,
request,
publish,
diff,
pull,
push,
makeLoader,
} from "@welshman/net"
import type {
AdapterContext,
AdapterFactory,
SocketPolicy,
RelayMessage,
Loader,
LoaderOptions,
RequestOptions,
PublishOptions,
DiffOptions,
PullOptions,
PushOptions,
} from "@welshman/net"
import {
getEventsById,
deriveEventsById,
deriveEvents,
makeDeriveEvent,
getEventsByIdByUrl,
deriveEventsByIdByUrl,
getEventsByIdForUrl,
deriveEventsByIdForUrl,
deriveItemsByKey,
deriveIsDeleted,
} from "@welshman/store"
import type {
EventsByIdOptions,
EventOptions,
EventsByIdByUrlOptions,
EventsByIdForUrlOptions,
ItemsByKey,
ItemsByKeyOptions,
} from "@welshman/store"
import type {User} from "./user.js" import type {User} from "./user.js"
import type {ClientPolicy} from "./policies.js"
export type ClientConfig = {
dufflepudUrl?: string
getDefaultRelays?: () => string[]
getIndexerRelays?: () => string[]
getSearchRelays?: () => string[]
}
export type ClientOptions = { export type ClientOptions = {
user?: User user?: User
config?: ClientConfig
getAdapter?: AdapterFactory getAdapter?: AdapterFactory
socketPolicies?: SocketPolicy[] socketPolicies?: SocketPolicy[]
policies?: ClientPolicy[]
} }
/** export interface IClient {
* 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 {
user?: User user?: User
config: ClientConfig
use: <T>(Ctor: new (ctx: IClient) => T) => T
netContext: AdapterContext
pool: Pool pool: Pool
tracker: Tracker tracker: Tracker
repository: Repository repository: Repository
wrapManager: WrapManager wrapManager: WrapManager
// Net utilities, with this client's context baked in
request: (options: Omit<RequestOptions, "context">) => ReturnType<typeof request>
publish: (options: Omit<PublishOptions, "context">) => ReturnType<typeof publish>
diff: (options: Omit<DiffOptions, "context">) => ReturnType<typeof diff>
pull: (options: Omit<PullOptions, "context">) => ReturnType<typeof pull>
push: (options: Omit<PushOptions, "context">) => ReturnType<typeof push>
makeLoader: (options: Omit<LoaderOptions, "context">) => Loader
load: Loader
// Store utilities, with this client's repository/tracker baked in
getEventsById: (options: Omit<EventsByIdOptions, "repository">) => ReturnType<typeof getEventsById>
deriveEventsById: (
options: Omit<EventsByIdOptions, "repository">,
) => ReturnType<typeof deriveEventsById>
deriveEvents: (options: Omit<EventsByIdOptions, "repository">) => ReturnType<typeof deriveEvents>
makeDeriveEvent: (options: Omit<EventOptions, "repository">) => ReturnType<typeof makeDeriveEvent>
getEventsByIdByUrl: (
options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">,
) => ReturnType<typeof getEventsByIdByUrl>
deriveEventsByIdByUrl: (
options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">,
) => ReturnType<typeof deriveEventsByIdByUrl>
getEventsByIdForUrl: (
options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">,
) => ReturnType<typeof getEventsByIdForUrl>
deriveEventsByIdForUrl: (
options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">,
) => ReturnType<typeof deriveEventsByIdForUrl>
deriveItemsByKey: <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) => Readable<ItemsByKey<T>>
deriveIsDeleted: (event: TrustedEvent) => ReturnType<typeof deriveIsDeleted>
} }
/** /**
* The core of an application instance. Owns the primitives a single identity * The core of an application instance. Owns the primitives a single identity
* needs (so data never bleeds across sessions) — a private repository, a socket * 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. * pool, a tracker, a wrap manager — and a `use` registry that resolves data
* * modules (including net/store helpers) on demand.
* Data modules are NOT fields on the client; they are composed separately (see
* `createApp`) and reach the client through the `ClientContext` seam.
*/ */
export class Client implements ClientContext { export class Client implements IClient {
user?: User user?: User
config: ClientConfig
netContext: AdapterContext
pool: Pool pool: Pool
tracker: Tracker tracker: Tracker
repository: Repository repository: Repository
wrapManager: WrapManager wrapManager: WrapManager
netContext: AdapterContext
load: Loader // Per-client singletons of data modules, keyed by constructor. Owned by the
ingestCleanup: Unsubscriber // client (so it's GC'd with the client — no WeakMap needed), this is what
// `use` memoizes against.
private singletons = new Map<Function, unknown>()
private policyCleanups: Unsubscriber[] = []
constructor(options: ClientOptions = {}) { constructor(options: ClientOptions = {}) {
this.user = options.user this.user = options.user
this.config = options.config ?? {}
this.pool = new Pool({ this.pool = new Pool({
makeSocket: (url: string) => { makeSocket: (url: string) => {
let socketPolicies = options.socketPolicies ?? defaultSocketPolicies let socketPolicies = options.socketPolicies ?? defaultSocketPolicies
@@ -148,89 +77,30 @@ export class Client implements ClientContext {
repository: this.repository, repository: this.repository,
getAdapter: options.getAdapter, 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. // Apply policies last, once the primitives and `use` registry exist. They
// The net layer doesn't do this for us, and it's how all the repository- // own all side effects; their cleanups run on `cleanup()`.
// backed collections (and gift-wrap unwrapping) get populated. this.policyCleanups = (options.policies ?? []).map(policy => policy(this))
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)
})
} }
ingest = (message: RelayMessage, url: string) => { // Resolve the per-client singleton of a data module, constructing it on first
if (!isRelayEvent(message)) return // use. This is how modules reach their dependencies (e.g. ctx.use(RelayLists)),
// replacing constructor injection and letting cycles resolve lazily.
use = <T>(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 return instance
if (!netContext.isEventValid(event, url)) return
this.tracker.track(event.id, url)
this.repository.publish(event)
} }
cleanup() { cleanup() {
this.ingestCleanup() this.policyCleanups.forEach(call)
this.pool.clear() this.pool.clear()
this.tracker.clear() this.tracker.clear()
this.repository.clear() this.repository.clear()
this.wrapManager.clear() this.wrapManager.clear()
} }
// Net utilities, bound to this client's context
request = (options: Omit<RequestOptions, "context">) =>
request({...options, context: this.netContext})
publish = (options: Omit<PublishOptions, "context">) =>
publish({...options, context: this.netContext})
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.netContext})
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.netContext})
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.netContext})
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.netContext})
// Store utilities, bound to this client's repository/tracker
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
getEventsById({...options, repository: this.repository})
deriveEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEventsById({...options, repository: this.repository})
deriveEvents = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEvents({...options, repository: this.repository})
makeDeriveEvent = (options: Omit<EventOptions, "repository">) =>
makeDeriveEvent({...options, repository: this.repository})
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
getEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository})
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository})
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
getEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository})
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository})
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
deriveItemsByKey<T>({...options, repository: this.repository})
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event)
} }
+4 -4
View File
@@ -3,7 +3,7 @@ import type {Readable, Unsubscriber} from "svelte/store"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store" import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
import type {MakeLoadItemOptions} 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 — * 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 * repository. The collection owns its own map and is its own Svelte store: its
* `subscribe` emits the underlying `Map`. * `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. * concrete `Client`, so they never create a dependency cycle.
*/ */
export class ClientData<T> { export class ClientData<T> {
@@ -20,7 +20,7 @@ export class ClientData<T> {
protected itemSubscribers: ((key: string, value: Maybe<T>) => void)[] = [] protected itemSubscribers: ((key: string, value: Maybe<T>) => void)[] = []
public derive: (key?: string, ...args: any[]) => Readable<Maybe<T>> public derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
constructor(protected readonly ctx: ClientContext) { constructor(protected readonly ctx: IClient) {
this.derive = makeDeriveItem(this.index) this.derive = makeDeriveItem(this.index)
} }
@@ -92,7 +92,7 @@ export abstract class LoadableData<T> extends ClientData<T> {
abstract fetch(key: string, ...args: any[]): Promise<unknown> abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(ctx: ClientContext, options: MakeLoadItemOptions = {}) { constructor(ctx: IClient, options: MakeLoadItemOptions = {}) {
super(ctx) super(ctx)
// Subclasses implement `fetch` as an arrow field, whose initializer runs // Subclasses implement `fetch` as an arrow field, whose initializer runs
+61 -52
View File
@@ -33,32 +33,18 @@ import {
prep, prep,
} from "@welshman/util" } from "@welshman/util"
import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util" import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util"
import {addMaximalFallbacks} from "./router.js" import {addMaximalFallbacks, Router} from "./router.js"
import type {Router} from "./router.js"
import {MergedThunk, publishThunk} from "./thunk.js" import {MergedThunk, publishThunk} from "./thunk.js"
import type {ThunkOptions} 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 {User} from "./user.js"
import type {RelayLists} from "./relayLists.js" import {RelayLists} from "./relayLists.js"
import type {MessagingRelayLists} from "./messagingRelayLists.js" import {MessagingRelayLists} from "./messagingRelayLists.js"
import type {BlockedRelayLists} from "./blockedRelayLists.js" import {BlockedRelayLists} from "./blockedRelayLists.js"
import type {SearchRelayLists} from "./searchRelayLists.js" import {SearchRelayLists} from "./searchRelayLists.js"
import type {FollowLists} from "./follows.js" import {FollowLists} from "./follows.js"
import type {MuteLists} from "./mutes.js" import {MuteLists} from "./mutes.js"
import type {PinLists} from "./pins.js" import {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
}
export type SendWrappedOptions = Omit< export type SendWrappedOptions = Omit<
ThunkOptions, ThunkOptions,
@@ -70,37 +56,54 @@ export type SendWrappedOptions = Omit<
/** /**
* The high-level "do an action" API: each method builds an event for the * 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 * client's user and publishes it via a thunk. Siblings (the user's lists, the
* functions; everything that used a global (current pubkey, signer, router, the * router) are resolved lazily through `ctx.use`; the acting user is `ctx.user`.
* user's lists) is now injected.
*/ */
export class Commands { export class Commands {
readonly client: ClientContext constructor(readonly ctx: IClient) {}
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(deps: CommandsDeps) { private get user(): User {
this.client = deps.client if (!this.ctx.user) {
this.user = deps.user throw new Error("Commands require a signed-in user")
this.router = deps.router }
this.relayLists = deps.relayLists
this.messagingRelayLists = deps.messagingRelayLists return this.ctx.user
this.blockedRelayLists = deps.blockedRelayLists }
this.searchRelayLists = deps.searchRelayLists
this.followLists = deps.followLists private get router() {
this.muteLists = deps.muteLists return this.ctx.use(Router)
this.pinLists = deps.pinLists }
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<ThunkOptions, "client" | "user">) => private publish = (options: Omit<ThunkOptions, "client" | "user">) =>
publishThunk({...options, client: this.client, user: this.user}) publishThunk({...options, client: this.ctx, user: this.user})
private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls() private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls()
@@ -149,7 +152,9 @@ export class Commands {
setReadRelays = async (urls: string[]) => { setReadRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) 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 writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
const readTags = urls.map(url => ["r", url, RelayMode.Read]) const readTags = urls.map(url => ["r", url, RelayMode.Read])
const tags = [...writeTags, ...readTags] const tags = [...writeTags, ...readTags]
@@ -160,7 +165,9 @@ export class Commands {
setWriteRelays = async (urls: string[]) => { setWriteRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) 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 readTags = readRelays.map(url => ["r", url, RelayMode.Read])
const writeTags = urls.map(url => ["r", url, RelayMode.Write]) const writeTags = urls.map(url => ["r", url, RelayMode.Write])
const tags = [...readTags, ...writeTags] const tags = [...readTags, ...writeTags]
@@ -259,14 +266,16 @@ export class Commands {
// NIP 02 // NIP 02
unfollow = async (value: string) => { 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) const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()}) return this.publish({event, relays: this.fromUser()})
} }
follow = async (tag: string[]) => { 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) const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()}) return this.publish({event, relays: this.fromUser()})
+8 -135
View File
@@ -1,141 +1,14 @@
import {Client} from "./client.js" import {Client} from "./client.js"
import type {ClientOptions} from "./client.js" import type {ClientOptions} from "./client.js"
import {Router} from "./router.js" import {defaultClientPolicies} from "./policies.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<RouterOptions, "getRelaysForPubkey" | "getRelayQuality">
}
/** /**
* Composes a default application instance: a `Client` plus the core data * Creates a batteries-included client: a `Client` wired with the default client
* modules, wired together. This is where genuine domain cycles (Router <-> * policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
* RelayLists, RelayStats <-> BlockedRelayLists) are broken — modules are given * Reach data modules via `client.use(Profiles)`, `client.use(Commands)`, etc.
* lazily-resolved closures that reach their siblings at call time, never at
* construction time.
* *
* Callers who want a different module set can ignore this helper and compose * For a bare client (no default side effects) construct `new Client(...)`
* their own bag directly, or spread additional modules onto the result. * directly, or pass your own `policies`.
*/ */
export const createApp = (options: AppOptions = {}) => { export const createApp = (options: ClientOptions = {}) =>
const client = new Client(options) new Client({...options, policies: options.policies ?? defaultClientPolicies})
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<typeof createApp>
+10 -13
View File
@@ -1,21 +1,18 @@
import {Scope, FeedController} from "@welshman/feeds" import {Scope, FeedController} from "@welshman/feeds"
import type {FeedControllerOptions, Feed} from "@welshman/feeds" import type {FeedControllerOptions, Feed} from "@welshman/feeds"
import type {AdapterContext} from "@welshman/net" import type {AdapterContext} from "@welshman/net"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {Wot} from "./wot.js" import {Wot} from "./wot.js"
export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed} export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
/** /**
* Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is * 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 * delegated to `Wot`, and feeds fetch through THIS client's net context (pool +
* context (pool + repository) rather than the global one. * repository) rather than the global one.
*/ */
export class Feeds { export class Feeds {
constructor( constructor(readonly ctx: IClient) {}
readonly ctx: ClientContext,
readonly wot: Wot,
) {}
getPubkeysForScope = (scope: Scope): string[] => { getPubkeysForScope = (scope: Scope): string[] => {
const $pubkey = this.ctx.user?.pubkey const $pubkey = this.ctx.user?.pubkey
@@ -28,11 +25,11 @@ export class Feeds {
case Scope.Self: case Scope.Self:
return [$pubkey] return [$pubkey]
case Scope.Follows: case Scope.Follows:
return this.wot.getFollows($pubkey) return this.ctx.use(Wot).getFollows($pubkey)
case Scope.Network: case Scope.Network:
return this.wot.getNetwork($pubkey) return this.ctx.use(Wot).getNetwork($pubkey)
case Scope.Followers: case Scope.Followers:
return this.wot.getFollowers($pubkey) return this.ctx.use(Wot).getFollowers($pubkey)
default: default:
return [] return []
} }
@@ -40,11 +37,11 @@ export class Feeds {
getPubkeysForWOTRange = (min: number, max: number): string[] => { getPubkeysForWOTRange = (min: number, max: number): string[] => {
const pubkeys = [] const pubkeys = []
const $maxWot = this.wot.getMaxWot() ?? 0 const $maxWot = this.ctx.use(Wot).getMaxWot() ?? 0
const thresholdMin = $maxWot * min const thresholdMin = $maxWot * min
const thresholdMax = $maxWot * max 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) { if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk) pubkeys.push(tpk)
} }
+4 -7
View File
@@ -1,18 +1,15 @@
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util" import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the * 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. * author's write relays), so it depends on the relay-list collection.
*/ */
export class FollowLists extends RepositoryCollection<ReturnType<typeof readList>> { export class FollowLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [FOLLOWS]}], filters: [{kinds: [FOLLOWS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -21,6 +18,6 @@ export class FollowLists extends RepositoryCollection<ReturnType<typeof readList
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(FOLLOWS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(FOLLOWS)(pubkey, relayHints)
} }
} }
+9 -38
View File
@@ -1,37 +1,22 @@
import {get, writable} from "svelte/store" import {get, writable} from "svelte/store"
import type {Unsubscriber} from "svelte/store" import {TaskQueue} from "@welshman/lib"
import {on, TaskQueue} from "@welshman/lib" import {getPubkeyTagValues} from "@welshman/util"
import {WRAP, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent, SignedEvent} from "@welshman/util" import type {TrustedEvent, SignedEvent} from "@welshman/util"
import {Nip59} from "@welshman/signer" import {Nip59} from "@welshman/signer"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
export type GiftWrapsOptions = {
shouldUnwrap?: boolean
}
/** /**
* Per-client gift-wrap (NIP-59) ingestion. Watches the client's repository for * Per-client gift-wrap (NIP-59) state: the unwrap queue plus failure/dedup
* kind-1059 wraps and unwraps the ones addressed to THIS client's user, storing * tracking. Scoped to `ctx.user`, so a client only ever unwraps its own user's
* the resulting rumors via the wrap manager. * messages into its own repository — which is what keeps DM history from being
* * merged across identities. The repository subscription that feeds it lives in
* In the old global model a single queue tried every logged-in account's signer * `clientPolicyGiftWraps`.
* against every wrap, depositing all rumors into one shared repository — which
* is exactly how DM history got merged across accounts. Here a client only ever
* unwraps its own user's messages into its own repository.
*/ */
export class GiftWraps { export class GiftWraps {
shouldUnwrap = writable(false)
failedUnwraps = new Set<string>() failedUnwraps = new Set<string>()
queue: TaskQueue<TrustedEvent> queue: TaskQueue<TrustedEvent>
cleanup: Unsubscriber
constructor(
readonly ctx: ClientContext,
options: GiftWrapsOptions = {},
) {
this.shouldUnwrap.set(options.shouldUnwrap ?? false)
constructor(readonly ctx: IClient) {
this.queue = new TaskQueue<TrustedEvent>({ this.queue = new TaskQueue<TrustedEvent>({
batchSize: 5, batchSize: 5,
batchDelay: 30, 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) => { enqueue = (wrap: TrustedEvent) => {
if (!get(this.shouldUnwrap)) return
if (this.failedUnwraps.has(wrap.id)) return if (this.failedUnwraps.has(wrap.id)) return
if (this.ctx.wrapManager.getRumor(wrap.id)) return if (this.ctx.wrapManager.getRumor(wrap.id)) return
+8 -11
View File
@@ -2,8 +2,8 @@ import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store" import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js" import {LoadableData} from "./clientData.js"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {Profiles} from "./profiles.js" import {Profiles} from "./profiles.js"
export type Handle = { export type Handle = {
nip05: string nip05: string
@@ -54,11 +54,7 @@ export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
* handle. * handle.
*/ */
export class Handles extends LoadableData<Handle> { export class Handles extends LoadableData<Handle> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly profiles: Profiles,
readonly options: {dufflepudUrl?: string} = {},
) {
super(ctx) super(ctx)
} }
@@ -66,9 +62,10 @@ export class Handles extends LoadableData<Handle> {
const result = new Map<string, Handle>() const result = new Map<string, Handle>()
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly // 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( 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 || []) { for (const {handle: nip05, info} of res?.data || []) {
@@ -99,7 +96,7 @@ export class Handles extends LoadableData<Handle> {
}) })
loadForPubkey = async (pubkey: string, relays: string[] = []) => { 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 return $profile?.nip05 ? this.load($profile.nip05) : undefined
} }
@@ -108,7 +105,7 @@ export class Handles extends LoadableData<Handle> {
this.loadForPubkey(pubkey, relays) this.loadForPubkey(pubkey, relays)
return deriveDeduplicated( return deriveDeduplicated(
[this.index, this.profiles.derive(pubkey, relays)], [this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$handlesByNip05, $profile]) => { ([$handlesByNip05, $profile]) => {
if (!$profile?.nip05) return undefined if (!$profile?.nip05) return undefined
+3
View File
@@ -1,4 +1,7 @@
export * from "./client.js" export * from "./client.js"
export * from "./policies.js"
export * from "./networking.js"
export * from "./stores.js"
export * from "./clientData.js" export * from "./clientData.js"
export * from "./repositoryCollection.js" export * from "./repositoryCollection.js"
export * from "./user.js" export * from "./user.js"
+4 -7
View File
@@ -1,8 +1,8 @@
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util" import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the * Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
@@ -10,10 +10,7 @@ import type {RelayLists} from "./relayLists.js"
* collection. * collection.
*/ */
export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof readList>> { export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [MESSAGING_RELAYS]}], filters: [{kinds: [MESSAGING_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -22,6 +19,6 @@ export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints)
} }
} }
+6 -10
View File
@@ -1,24 +1,20 @@
import {MUTES, asDecryptedEvent, readList} from "@welshman/util" import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util" import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {RelayLists} from "./relayLists.js" import {RelayLists} from "./relayLists.js"
import type {Plaintext} from "./plaintext.js" import {Plaintext} from "./plaintext.js"
/** /**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in * Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, so decoding goes through the plaintext cache. * encrypted content, so decoding goes through the plaintext cache.
*/ */
export class MuteLists extends RepositoryCollection<PublishedList> { export class MuteLists extends RepositoryCollection<PublishedList> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
readonly plaintext: Plaintext,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [MUTES]}], filters: [{kinds: [MUTES]}],
eventToItem: async (event: TrustedEvent) => { 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 // 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 — // no signer is available, don't cache a result with empty private tags —
@@ -37,6 +33,6 @@ export class MuteLists extends RepositoryCollection<PublishedList> {
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(MUTES)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(MUTES)(pubkey, relayHints)
} }
} }
+38
View File
@@ -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<RequestOptions, "context">) =>
request({...options, context: this.ctx.netContext})
publish = (options: Omit<PublishOptions, "context">) =>
publish({...options, context: this.ctx.netContext})
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.ctx.netContext})
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.ctx.netContext})
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.ctx.netContext})
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
}
+4 -7
View File
@@ -1,18 +1,15 @@
import {PINS, asDecryptedEvent, readList} from "@welshman/util" import {PINS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model * 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. * (the author's write relays), so it depends on the relay-list collection.
*/ */
export class PinLists extends RepositoryCollection<ReturnType<typeof readList>> { export class PinLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [PINS]}], filters: [{kinds: [PINS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -21,6 +18,6 @@ export class PinLists extends RepositoryCollection<ReturnType<typeof readList>>
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(PINS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(PINS)(pubkey, relayHints)
} }
} }
+87
View File
@@ -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,
]
+5 -8
View File
@@ -1,18 +1,15 @@
import {derived, readable} from "svelte/store" import {derived, readable} from "svelte/store"
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util" import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's * 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<ReturnType<typeof readProfile>> { export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [PROFILE]}], filters: [{kinds: [PROFILE]}],
eventToItem: readProfile, eventToItem: readProfile,
@@ -21,7 +18,7 @@ export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(PROFILE)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(PROFILE)(pubkey, relayHints)
} }
display = (pubkey: string | undefined) => display = (pubkey: string | undefined) =>
+17 -21
View File
@@ -10,24 +10,18 @@ import {
} from "@welshman/util" } from "@welshman/util"
import type {Filter, TrustedEvent, PublishedList} from "@welshman/util" import type {Filter, TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import {addMinimalFallbacks} from "./router.js" import {Router, addMinimalFallbacks} from "./router.js"
import type {Router} from "./router.js" import {Networking} from "./networking.js"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
/** /**
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every * NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* other outbox-model load depends on, so it also exposes `loadUsingOutbox` / * outbox-model load depends on, so it also exposes `loadUsingOutbox` /
* `makeOutboxLoader` for other collections to build their fetchers on. * `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.
* 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.
*/ */
export class RelayLists extends RepositoryCollection<PublishedList> { export class RelayLists extends RepositoryCollection<PublishedList> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly router: Router,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [RELAYS]}], filters: [{kinds: [RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -37,11 +31,12 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}] const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
const router = this.ctx.use(Router)
return Promise.all([ return Promise.all([
this.ctx.load({filters, relays: this.router.FromRelays(relayHints).getUrls()}), this.ctx.use(Networking).load({filters, relays: router.FromRelays(relayHints).getUrls()}),
this.ctx.load({filters, relays: this.router.FromPubkey(pubkey).getUrls()}), this.ctx.use(Networking).load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
this.ctx.load({filters, relays: this.router.Index().getUrls()}), this.ctx.use(Networking).load({filters, relays: router.Index().getUrls()}),
]) ])
} }
@@ -53,7 +48,8 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => { loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write) const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write)
const allRelays = this.router const allRelays = this.ctx
.use(Router)
.FromRelays(writeRelays) .FromRelays(writeRelays)
.policy(addMinimalFallbacks) .policy(addMinimalFallbacks)
.limit(8) .limit(8)
@@ -64,7 +60,7 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
} }
for (const relays of chunk(2, allRelays)) { 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) { if (events.length > 0) {
return first(sortEventsDesc(events)) return first(sortEventsDesc(events))
@@ -76,10 +72,10 @@ export class RelayLists extends RepositoryCollection<PublishedList> {
(kind: number, filter: Filter = {}) => (kind: number, filter: Filter = {}) =>
async (pubkey: string, relayHints: string[] = []) => { async (pubkey: string, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] 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([ await Promise.all([
this.ctx.load({filters, relays}), this.ctx.use(Networking).load({filters, relays}),
this.loadUsingOutbox(kind, pubkey, filter), this.loadUsingOutbox(kind, pubkey, filter),
]) ])
} }
+26 -67
View File
@@ -1,10 +1,9 @@
import type {Unsubscriber} from "svelte/store"
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib" import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util" import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus, SocketEvent} from "@welshman/net" import {SocketStatus} from "@welshman/net"
import type {Socket, ClientMessage, RelayMessage} from "@welshman/net" import type {ClientMessage, RelayMessage} from "@welshman/net"
import {ClientData} from "./clientData.js" import {ClientData} from "./clientData.js"
import type {ClientContext} from "./client.js" import {BlockedRelayLists} from "./blockedRelayLists.js"
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void] export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
@@ -52,45 +51,22 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
notice_count: 0, 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 * Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic
* client's pool, and exposes a `getQuality` heuristic the router uses to rank * the router uses to rank relays. A pure store — the socket wiring that fills it
* relays. A "local" collection — its data isn't backed by the repository. * lives in `clientPolicyRelayStats`.
*/ */
export class RelayStats extends ClientData<RelayStatsItem> { export class RelayStats extends ClientData<RelayStatsItem> {
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) => { getQuality = (url: string) => {
// Skip non-relays entirely // Skip non-relays entirely
if (!isRelayUrl(url)) return 0 if (!isRelayUrl(url)) return 0
// Skip blocked relays (when a host app provides the check) // Skip relays the user has blocked
if (this.statsOptions.isRelayBlocked?.(url)) return 0 const pubkey = this.ctx.user?.pubkey
if (pubkey && this.ctx.use(BlockedRelayLists).getBlockedRelays(pubkey).includes(url)) {
return 0
}
const stats = this.get(url) const stats = this.get(url)
@@ -116,9 +92,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
return 0.7 return 0.7
} }
// Utilities for syncing stats from connections to relays private update = batch(150, (batched: RelayStatsUpdate[]) => {
private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => {
for (const [url, updates] of groupBy(([url]) => url, batched)) { for (const [url, updates] of groupBy(([url]) => url, batched)) {
if (!url || !isRelayUrl(url)) { if (!url || !isRelayUrl(url)) {
console.warn(`Attempted to update stats for an invalid relay url: ${url}`) console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
@@ -136,9 +110,9 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
}) })
private onSocketSend = ([verb]: ClientMessage, url: string) => { onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") { if (verb === "REQ") {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.request_count++ stats.request_count++
@@ -146,7 +120,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}, },
]) ])
} else if (verb === "EVENT") { } else if (verb === "EVENT") {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.publish_count++ stats.publish_count++
@@ -156,11 +130,11 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
} }
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") { if (verb === "OK") {
const [, ok] = extra const [, ok] = extra
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
if (ok) { if (ok) {
@@ -171,14 +145,9 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}, },
]) ])
} else if (verb === "AUTH") { } else if (verb === "AUTH") {
this.updateRelayStats([ this.update([url, stats => (stats.last_auth = now())])
url,
stats => {
stats.last_auth = now()
},
])
} else if (verb === "EVENT") { } else if (verb === "EVENT") {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.event_count++ stats.event_count++
@@ -186,25 +155,15 @@ export class RelayStats extends ClientData<RelayStatsItem> {
}, },
]) ])
} else if (verb === "EOSE") { } else if (verb === "EOSE") {
this.updateRelayStats([ this.update([url, stats => stats.eose_count++])
url,
stats => {
stats.eose_count++
},
])
} else if (verb === "NOTICE") { } else if (verb === "NOTICE") {
this.updateRelayStats([ this.update([url, stats => stats.notice_count++])
url,
stats => {
stats.notice_count++
},
])
} }
} }
private onSocketStatus = (status: string, url: string) => { onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) { if (status === SocketStatus.Open) {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.last_open = now() stats.last_open = now()
@@ -214,7 +173,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
if (status === SocketStatus.Closed) { if (status === SocketStatus.Closed) {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.last_close = now() stats.last_close = now()
@@ -224,7 +183,7 @@ export class RelayStats extends ClientData<RelayStatsItem> {
} }
if (status === SocketStatus.Error) { if (status === SocketStatus.Error) {
this.updateRelayStats([ this.update([
url, url,
stats => { stats => {
stats.last_error = now() stats.last_error = now()
+5 -4
View File
@@ -3,7 +3,8 @@ import type {Maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store" import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store"
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} 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<T> = { export type RepositoryCollectionOptions<T> = {
filters: Filter[] filters: Filter[]
@@ -19,7 +20,7 @@ export type RepositoryCollectionOptions<T> = {
* `fetch` (how to load an item by key from the network) and pass the * `fetch` (how to load an item by key from the network) and pass the
* filters/decoder via `super`. * 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<T> { export abstract class RepositoryCollection<T> {
byKey: Readable<ItemsByKey<T>> byKey: Readable<ItemsByKey<T>>
@@ -40,12 +41,12 @@ export abstract class RepositoryCollection<T> {
abstract fetch(key: string, ...args: any[]): Promise<unknown> abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor( constructor(
protected readonly ctx: ClientContext, protected readonly ctx: IClient,
options: RepositoryCollectionOptions<T>, options: RepositoryCollectionOptions<T>,
) { ) {
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args) const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
this.byKey = ctx.deriveItemsByKey<T>({ this.byKey = ctx.use(Stores).deriveItemsByKey<T>({
filters: options.filters, filters: options.filters,
eventToItem: options.eventToItem, eventToItem: options.eventToItem,
getKey: options.getKey, getKey: options.getKey,
+13 -56
View File
@@ -11,55 +11,15 @@ import {
RelayMode, RelayMode,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter} 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 = { export type RelaysAndFilters = {
relays: string[] relays: string[]
filters: Filter[] 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 = { export type Selection = {
weight: number weight: number
relays: string[] relays: string[]
@@ -83,15 +43,12 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou
// Router class // Router class
export class Router { export class Router {
constructor( constructor(readonly ctx: IClient) {}
readonly ctx: ClientContext,
readonly options: RouterOptions,
) {}
// Utilities derived from options // Utilities derived from the relay-list collection and client config
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) => getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
this.options.getRelaysForPubkey?.(pubkey, mode) || [] this.ctx.use(RelayLists).getRelaysForPubkey(pubkey, mode)
getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) => getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) =>
pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode)) pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode))
@@ -113,11 +70,11 @@ export class Router {
FromRelays = (relays: string[]) => this.scenario([makeSelection(relays)]) 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)) ForUser = () => this.FromRelays(this.getRelaysForUser(RelayMode.Read))
@@ -251,9 +208,9 @@ export class RouterScenario {
weight = (scale: number) => weight = (scale: number) =>
this.update(selection => ({...selection, weight: selection.weight * scale})) 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 = () => { getUrls = () => {
const limit = this.getLimit() const limit = this.getLimit()
@@ -274,7 +231,7 @@ export class RouterScenario {
const scoreRelay = (relay: string) => { const scoreRelay = (relay: string) => {
const weight = relayWeights.get(relay)! 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. // 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 // 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 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) const fallbackRelays = shuffle(allFallbackRelays).slice(0, fallbacksNeeded)
for (const fallbackRelay of fallbackRelays) { for (const fallbackRelay of fallbackRelays) {
+18 -25
View File
@@ -7,13 +7,15 @@ import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE} from "@welshman/util" import {PROFILE} from "@welshman/util"
import type {PublishedProfile, RelayProfile} from "@welshman/util" import type {PublishedProfile, RelayProfile} from "@welshman/util"
import {throttled, deriveItems} from "@welshman/store" import {throttled, deriveItems} from "@welshman/store"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {Router} from "./router.js" import {Networking} from "./networking.js"
import type {Profiles} from "./profiles.js" import {Router} from "./router.js"
import type {Topics, Topic} from "./topics.js" import {Profiles} from "./profiles.js"
import type {Relays} from "./relays.js" import {Topics} from "./topics.js"
import type {Handles} from "./handles.js" import type {Topic} from "./topics.js"
import type {Wot} from "./wot.js" import {Relays} from "./relays.js"
import {Handles} from "./handles.js"
import {Wot} from "./wot.js"
export type SearchOptions<V, T> = { export type SearchOptions<V, T> = {
getValue: (item: T) => V getValue: (item: T) => V
@@ -65,22 +67,13 @@ export class Searches {
topicSearch: Readable<Search<string, Topic>> topicSearch: Readable<Search<string, Topic>>
relaySearch: Readable<Search<string, RelayProfile>> relaySearch: Readable<Search<string, RelayProfile>>
constructor( constructor(readonly ctx: IClient) {
readonly ctx: ClientContext,
readonly router: Router,
readonly profiles: Profiles,
readonly topics: Topics,
readonly relays: Relays,
readonly handles: Handles,
readonly wot: Wot,
) {
this.profileSearch = derived( 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]) => { ([$profiles, $handlesByNip05]) => {
// Remove invalid nip05's from profiles // Remove invalid nip05's from profiles
const options = $profiles.map(p => { const options = $profiles.map(p => {
const isNip05Valid = const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
!p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
return isNip05Valid ? p : {...p, nip05: ""} return isNip05Valid ? p : {...p, nip05: ""}
}) })
@@ -89,9 +82,9 @@ export class Searches {
onSearch: this.searchProfiles, onSearch: this.searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey, getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => { 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: { fuseOptions: {
keys: [ 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, { createSearch($topics, {
getValue: (topic: Topic) => topic.name, getValue: (topic: Topic) => topic.name,
fuseOptions: {keys: ["name"]}, fuseOptions: {keys: ["name"]},
}), }),
) )
this.relaySearch = derived(deriveItems(this.relays), $relays => this.relaySearch = derived(deriveItems(this.ctx.use(Relays)), $relays =>
createSearch($relays, { createSearch($relays, {
getValue: (relay: RelayProfile) => relay.url, getValue: (relay: RelayProfile) => relay.url,
fuseOptions: { fuseOptions: {
@@ -126,9 +119,9 @@ export class Searches {
searchProfiles = debounce(500, (search: string) => { searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) { if (search.length > 2) {
this.ctx.load({ this.ctx.use(Networking).load({
filters: [{kinds: [PROFILE], search}], filters: [{kinds: [PROFILE], search}],
relays: this.router.Search().getUrls(), relays: this.ctx.use(Router).Search().getUrls(),
}) })
} }
}) })
+4 -7
View File
@@ -1,8 +1,8 @@
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util" import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js" import {RepositoryCollection} from "./repositoryCollection.js"
import type {ClientContext} from "./client.js" import {RelayLists} from "./relayLists.js"
import type {RelayLists} from "./relayLists.js" import type {IClient} from "./client.js"
/** /**
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the * NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
@@ -10,10 +10,7 @@ import type {RelayLists} from "./relayLists.js"
* collection. * collection.
*/ */
export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof readList>> { export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly relayLists: RelayLists,
) {
super(ctx, { super(ctx, {
filters: [{kinds: [SEARCH_RELAYS]}], filters: [{kinds: [SEARCH_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
@@ -22,6 +19,6 @@ export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof rea
} }
fetch(pubkey: string, relayHints: string[] = []) { fetch(pubkey: string, relayHints: string[] = []) {
return this.relayLists.makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints) return this.ctx.use(RelayLists).makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints)
} }
} }
+2 -1
View File
@@ -196,7 +196,8 @@ export const getSigner = (session?: Session): ISigner | undefined => {
if (isNip07Session(session)) return wrapSigner(new Nip07Signer()) if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret)) if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey)) 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)) { if (isNip46Session(session)) {
const { const {
secret: clientSecret, secret: clientSecret,
+58
View File
@@ -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<EventsByIdOptions, "repository">) =>
getEventsById({...options, repository: this.ctx.repository})
deriveEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEventsById({...options, repository: this.ctx.repository})
deriveEvents = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEvents({...options, repository: this.ctx.repository})
makeDeriveEvent = (options: Omit<EventOptions, "repository">) =>
makeDeriveEvent({...options, repository: this.ctx.repository})
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
getEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
getEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
deriveItemsByKey<T>({...options, repository: this.ctx.repository})
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event)
}
+12 -14
View File
@@ -1,7 +1,8 @@
import {isSignedEvent} from "@welshman/util" import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util" import type {Filter, SignedEvent} from "@welshman/util"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {Relays} from "./relays.js" import {Networking} from "./networking.js"
import {Relays} from "./relays.js"
export type AppSyncOpts = { export type AppSyncOpts = {
relays: string[] relays: string[]
@@ -12,19 +13,16 @@ export type AppSyncOpts = {
* Negentropy-aware sync. Pulls/pushes events between the local repository and a * 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 * 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 * 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 { export class Sync {
constructor( constructor(readonly ctx: IClient) {}
readonly ctx: ClientContext,
readonly relays: Relays,
) {}
query = (filters: Filter[]) => query = (filters: Filter[]) =>
this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
hasNegentropy = (url: string) => { hasNegentropy = (url: string) => {
const relay = this.relays.get(url) const relay = this.ctx.use(Relays).get(url)
if (relay?.negentropy) return true if (relay?.negentropy) return true
if (relay?.supported_nips?.includes?.("77")) return true if (relay?.supported_nips?.includes?.("77")) return true
@@ -34,27 +32,27 @@ export class Sync {
} }
pull = async ({relays, filters}: AppSyncOpts) => { pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const events = this.query(filters).filter(isSignedEvent) const events = this.query(filters).filter(isSignedEvent)
await Promise.all( await Promise.all(
relays.map(async relay => { relays.map(async relay => {
await (this.hasNegentropy(relay) await (this.hasNegentropy(relay)
? this.ctx.pull({filters, events, relays: [relay]}) ? net.pull({filters, events, relays: [relay]})
: this.ctx.request({filters, relays: [relay], autoClose: true})) : net.request({filters, relays: [relay], autoClose: true}))
}), }),
) )
} }
push = async ({relays, filters}: AppSyncOpts) => { push = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const events = this.query(filters).filter(isSignedEvent) const events = this.query(filters).filter(isSignedEvent)
await Promise.all( await Promise.all(
relays.map(async relay => { relays.map(async relay => {
await (this.hasNegentropy(relay) await (this.hasNegentropy(relay)
? this.ctx.push({filters, events, relays: [relay]}) ? net.push({filters, events, relays: [relay]})
: Promise.all( : Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]}))))
events.map((event: SignedEvent) => this.ctx.publish({event, relays: [relay]})),
))
}), }),
) )
} }
+14 -18
View File
@@ -8,9 +8,9 @@ import {
isShareableRelayUrl, isShareableRelayUrl,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import type {Router} from "./router.js" import {Router} from "./router.js"
import type {Profiles} from "./profiles.js" import {Profiles} from "./profiles.js"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
/** /**
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router * 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. * user to avoid self-tagging.
*/ */
export class Tags { export class Tags {
constructor( constructor(readonly ctx: IClient) {}
readonly ctx: ClientContext,
readonly router: Router,
readonly profiles: Profiles,
) {}
tagZapSplit = (pubkey: string, split = 1) => [ tagZapSplit = (pubkey: string, split = 1) => [
"zap", "zap",
pubkey, pubkey,
this.router.FromPubkey(pubkey).getUrl() || "", this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
String(split), String(split),
] ]
tagPubkey = (pubkey: string) => [ tagPubkey = (pubkey: string) => [
"p", "p",
pubkey, pubkey,
this.router.FromPubkey(pubkey).getUrl() || "", this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
this.profiles.display(pubkey), this.ctx.use(Profiles).display(pubkey),
] ]
tagEvent = (event: TrustedEvent, url = "", mark = "") => { tagEvent = (event: TrustedEvent, url = "", mark = "") => {
if (!url) { 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]] const tags = [["e", event.id, url, mark, event.pubkey]]
@@ -58,7 +54,7 @@ export class Tags {
).map(pubkey => this.tagPubkey(pubkey)) ).map(pubkey => this.tagPubkey(pubkey))
tagEventForQuote = (event: TrustedEvent, relay?: string) => { 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] return ["q", event.id, hint, event.pubkey]
} }
@@ -68,13 +64,13 @@ export class Tags {
const {roots, replies} = getReplyTags(event.tags) const {roots, replies} = getReplyTags(event.tags)
const parents = roots.length > 0 ? roots : replies const parents = roots.length > 0 ? roots : replies
const mark = parents.length > 0 ? "reply" : "root" 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 // If the parent included roots use them, otherwise use replies as a fallback
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) { for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
const hint = isShareableRelayUrl(originalHint) const hint = isShareableRelayUrl(originalHint)
? originalHint ? originalHint
: this.router.EventRoots(event).getUrl() : this.ctx.use(Router).EventRoots(event).getUrl()
tags.push([k, id, hint || "", "root", pubkey]) tags.push([k, id, hint || "", "root", pubkey])
} }
@@ -91,8 +87,8 @@ export class Tags {
} }
tagEventForComment = (event: TrustedEvent, relay?: string) => { tagEventForComment = (event: TrustedEvent, relay?: string) => {
const pubkeyHint = this.router.FromPubkey(event.pubkey).getUrl() || "" const pubkeyHint = this.ctx.use(Router).FromPubkey(event.pubkey).getUrl() || ""
const eventHint = relay || this.router.Event(event).getUrl() || "" const eventHint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
const address = getAddress(event) const address = getAddress(event)
const seenRoots = new Set<string>() const seenRoots = new Set<string>()
const tags: string[][] = [] const tags: string[][] = []
@@ -126,7 +122,7 @@ export class Tags {
} }
tagEventForReaction = (event: TrustedEvent, relay?: string) => { 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[][] = [] const tags: string[][] = []
// Mention the event's author // Mention the event's author
+5 -4
View File
@@ -13,14 +13,15 @@ import {
} from "@welshman/util" } from "@welshman/util"
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net" import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer" import {Nip01Signer, Nip59} from "@welshman/signer"
import type {ClientContext} from './client.js' import type {IClient} from "./client.js"
import type {User} from './user.js' import {Networking} from "./networking.js"
import type {User} from "./user.js"
export type ThunkOptions = Override< export type ThunkOptions = Override<
PublishOptions, PublishOptions,
{ {
user: User user: User
client: ClientContext client: IClient
event: EventTemplate event: EventTemplate
recipient?: string recipient?: string
delay?: number delay?: number
@@ -111,7 +112,7 @@ export class Thunk {
} }
// Send it off // Send it off
await this.options.client.publish({ await this.options.client.use(Networking).publish({
...this.options, ...this.options,
event, event,
onSuccess: (result: PublishResult) => { onSuccess: (result: PublishResult) => {
+2 -2
View File
@@ -3,7 +3,7 @@ import type {Readable} from "svelte/store"
import {on} from "@welshman/lib" import {on} from "@welshman/lib"
import {getTopicTagValues} from "@welshman/util" import {getTopicTagValues} from "@welshman/util"
import {deriveItems} from "@welshman/store" import {deriveItems} from "@welshman/store"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
export type Topic = { export type Topic = {
name: string name: string
@@ -18,7 +18,7 @@ export class Topics {
byName: Readable<Map<string, Topic>> byName: Readable<Map<string, Topic>>
all: Readable<Topic[]> all: Readable<Topic[]>
constructor(readonly ctx: ClientContext) { constructor(readonly ctx: IClient) {
const topicsByName = new Map<string, Topic>() const topicsByName = new Map<string, Topic>()
const addTopic = (name: string) => { const addTopic = (name: string) => {
+18 -15
View File
@@ -3,9 +3,9 @@ import type {Readable, Writable} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib" import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util" import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled, getter} from "@welshman/store" import {throttled, getter} from "@welshman/store"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {FollowLists} from "./follows.js" import {FollowLists} from "./follows.js"
import type {MuteLists} from "./mutes.js" import {MuteLists} from "./mutes.js"
/** /**
* Web-of-trust scoring derived from follow and mute lists. The trust graph is * Web-of-trust scoring derived from follow and mute lists. The trust graph is
@@ -23,12 +23,11 @@ export class Wot {
private getWotGraphStore: () => Map<string, number> private getWotGraphStore: () => Map<string, number>
private getMaxWotStore: () => number | undefined private getMaxWotStore: () => number | undefined
constructor( constructor(readonly ctx: IClient) {
readonly ctx: ClientContext, const followLists = this.ctx.use(FollowLists)
readonly followLists: FollowLists, const muteLists = this.ctx.use(MuteLists)
readonly muteLists: MuteLists,
) { this.followersByPubkey = derived(throttled(1000, followLists.all), lists => {
this.followersByPubkey = derived(throttled(1000, this.followLists.all), lists => {
const $followersByPubkey = new Map<string, Set<string>>() const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists) { for (const list of lists) {
@@ -40,7 +39,7 @@ export class Wot {
return $followersByPubkey return $followersByPubkey
}) })
this.mutersByPubkey = derived(throttled(1000, this.muteLists.all), lists => { this.mutersByPubkey = derived(throttled(1000, muteLists.all), lists => {
const $mutersByPubkey = new Map<string, Set<string>>() const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists) { for (const list of lists) {
@@ -61,13 +60,15 @@ export class Wot {
this.getWotGraphStore = getter(this.wotGraph) this.getWotGraphStore = getter(this.wotGraph)
this.getMaxWotStore = getter(this.maxWot) this.getMaxWotStore = getter(this.maxWot)
this.followLists.subscribe(this.buildGraph) followLists.subscribe(this.buildGraph)
this.muteLists.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) => { getNetwork = (pubkey: string) => {
const pubkeys = new Set(this.getFollows(pubkey)) const pubkeys = new Set(this.getFollows(pubkey))
@@ -105,7 +106,9 @@ export class Wot {
buildGraph = throttle(1000, () => { buildGraph = throttle(1000, () => {
const $pubkey = this.ctx.user?.pubkey const $pubkey = this.ctx.user?.pubkey
const $graph = new Map<string, number>() const $graph = new Map<string, number>()
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 follow of $follows) {
for (const pubkey of this.getFollows(follow)) { for (const pubkey of this.getFollows(follow)) {
+9 -13
View File
@@ -12,8 +12,8 @@ import {getTagValues, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util" import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store" import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js" import {LoadableData} from "./clientData.js"
import type {ClientContext} from "./client.js" import type {IClient} from "./client.js"
import type {Profiles} from "./profiles.js" import {Profiles} from "./profiles.js"
/** /**
* Lightning zapper info, keyed by lnurl. A "local" loadable collection: items * 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. * profiles collection to resolve a pubkey's lnurl.
*/ */
export class Zappers extends LoadableData<Zapper> { export class Zappers extends LoadableData<Zapper> {
constructor( constructor(ctx: IClient) {
ctx: ClientContext,
readonly profiles: Profiles,
readonly options: {dufflepudUrl?: string} = {},
) {
super(ctx) super(ctx)
} }
@@ -45,11 +41,11 @@ export class Zappers extends LoadableData<Zapper> {
} }
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly // 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 hexUrls = valid.map(bech32ToHex)
const res: any = await tryCatch( const res: any = await tryCatch(
async () => 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 || []) { for (const {lnurl, info} of res?.data || []) {
@@ -71,7 +67,7 @@ export class Zappers extends LoadableData<Zapper> {
}) })
loadForPubkey = async (pubkey: string, relays: string[] = []) => { 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 return $profile?.lnurl ? this.load($profile.lnurl) : undefined
} }
@@ -80,7 +76,7 @@ export class Zappers extends LoadableData<Zapper> {
this.loadForPubkey(pubkey, relays) this.loadForPubkey(pubkey, relays)
return deriveDeduplicated( return deriveDeduplicated(
[this.index, this.profiles.derive(pubkey, relays)], [this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$zappersByLnurl, $profile]) => ([$zappersByLnurl, $profile]) =>
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined, $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined,
) )
@@ -90,7 +86,7 @@ export class Zappers extends LoadableData<Zapper> {
const pubkeys = getTagValues("zap", event.tags) const pubkeys = getTagValues("zap", event.tags)
if (pubkeys.length > 0) { 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)) const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
if (lnurls.length > 0) { if (lnurls.length > 0) {
@@ -98,7 +94,7 @@ export class Zappers extends LoadableData<Zapper> {
} }
} }
const profile = await this.profiles.load(event.pubkey) const profile = await this.ctx.use(Profiles).load(event.pubkey)
return removeUndefined([profile?.lnurl]) return removeUndefined([profile?.lnurl])
} }