diff --git a/packages/client/package.json b/packages/client/package.json index ecc87b4..6b92cac 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -3,7 +3,7 @@ "version": "0.8.13", "author": "hodlbod", "license": "MIT", - "description": "A client god-object for building nostr applications", + "description": "An instance-based, composable client for building nostr applications", "publishConfig": { "access": "public" }, diff --git a/packages/client/src/blockedRelayLists.ts b/packages/client/src/blockedRelayLists.ts new file mode 100644 index 0000000..0b45ba6 --- /dev/null +++ b/packages/client/src/blockedRelayLists.ts @@ -0,0 +1,29 @@ +import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model, + * so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so + * blocked relays are never selected. + */ +export class BlockedRelayLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [BLOCKED_RELAYS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: list => list.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints) + } + + getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey)) +} diff --git a/packages/client/src/blossom.ts b/packages/client/src/blossom.ts new file mode 100644 index 0000000..5545555 --- /dev/null +++ b/packages/client/src/blossom.ts @@ -0,0 +1,26 @@ +import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox + * model (the author's write relays), so it depends on the relay-list collection. + */ +export class BlossomServerLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [BLOSSOM_SERVERS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: list => list.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints) + } +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 178e46f..6978192 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,7 +1,57 @@ -import {Maybe} from '@welshman/lib' -import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net' -import {RelayStats} from './relayStats.js' -import {User} from './user.js' +import type {Readable, Unsubscriber} from "svelte/store" +import {isDVMKind, isEphemeralKind} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import { + 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" export type ClientOptions = { user?: User @@ -9,26 +59,82 @@ export type ClientOptions = { socketPolicies?: SocketPolicy[] } -export class Client { +/** + * 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 pool: Pool tracker: Tracker repository: Repository wrapManager: WrapManager - netContext: NetContext - relayStats: RelayStats - constructor(options: ClientOptions) { + // Net utilities, with this client's context baked in + request: (options: Omit) => ReturnType + publish: (options: Omit) => ReturnType + diff: (options: Omit) => ReturnType + pull: (options: Omit) => ReturnType + push: (options: Omit) => ReturnType + makeLoader: (options: Omit) => Loader + load: Loader + + // Store utilities, with this client's repository/tracker baked in + getEventsById: (options: Omit) => ReturnType + deriveEventsById: ( + options: Omit, + ) => ReturnType + deriveEvents: (options: Omit) => ReturnType + makeDeriveEvent: (options: Omit) => ReturnType + getEventsByIdByUrl: ( + options: Omit, + ) => ReturnType + deriveEventsByIdByUrl: ( + options: Omit, + ) => ReturnType + getEventsByIdForUrl: ( + options: Omit, + ) => ReturnType + deriveEventsByIdForUrl: ( + options: Omit, + ) => ReturnType + deriveItemsByKey: (options: Omit, "repository">) => Readable> + deriveIsDeleted: (event: TrustedEvent) => ReturnType +} + +/** + * The core of an application instance. Owns the primitives a single identity + * needs (so data never bleeds across sessions) — a private repository, a socket + * pool, a tracker, a wrap manager — plus net/store helpers bound to them. + * + * Data modules are NOT fields on the client; they are composed separately (see + * `createApp`) and reach the client through the `ClientContext` seam. + */ +export class Client implements ClientContext { + user?: User + pool: Pool + tracker: Tracker + repository: Repository + wrapManager: WrapManager + netContext: AdapterContext + load: Loader + ingestCleanup: Unsubscriber + + constructor(options: ClientOptions = {}) { this.user = options.user this.pool = new Pool({ makeSocket: (url: string) => { - const socketPolicies = options.socketPolicies ?? defaultSocketPolicies + let socketPolicies = options.socketPolicies ?? defaultSocketPolicies if (this.user) { socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()] } - return makeSocket(url, socketPolicies) + return new Socket(url, socketPolicies) }, }) this.tracker = new Tracker() @@ -47,58 +153,84 @@ export class Client { timeout: 3000, threshold: 0.5, }) + + // Ingest every event received on any socket into this client's repository. + // The net layer doesn't do this for us, and it's how all the repository- + // backed collections (and gift-wrap unwrapping) get populated. + this.ingestCleanup = this.pool.subscribe(socket => { + const onReceive = (message: RelayMessage) => this.ingest(message, socket.url) + + socket.on(SocketEvent.Receive, onReceive) + + return () => socket.off(SocketEvent.Receive, onReceive) + }) + } + + ingest = (message: RelayMessage, url: string) => { + if (!isRelayEvent(message)) return + + const event = message[2] + + if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return + if (!netContext.isEventValid(event, url)) return + + this.tracker.track(event.id, url) + this.repository.publish(event) } cleanup() { + this.ingestCleanup() this.pool.clear() this.tracker.clear() this.repository.clear() this.wrapManager.clear() - this.relayStats.cleanup() } - // Bind net utilities to client net context + // Net utilities, bound to this client's context - request = (options: RequestOptions) => request({...context: this.netContext, ...options}) + request = (options: Omit) => + request({...options, context: this.netContext}) - makeLoader = (options: LoaderOptions) => makeLoader({...context: this.netContext, ...options}) + publish = (options: Omit) => + publish({...options, context: this.netContext}) - publish = (options: PublishOptions) => publish({...context: this.netContext, ...options}) + diff = (options: Omit) => diff({...options, context: this.netContext}) - diff = (options: DiffOptions) => diff({...context: this.netContext, ...options}) + pull = (options: Omit) => pull({...options, context: this.netContext}) - pull = (options: PullOptions) => pull({...context: this.netContext, ...options}) + push = (options: Omit) => push({...options, context: this.netContext}) - push = (options: PushOptions) => push({...context: this.netContext, ...options}) + makeLoader = (options: Omit): Loader => + makeLoader({...options, context: this.netContext}) - // Bind store utilities to client stuff + // Store utilities, bound to this client's repository/tracker - getEventsById = (options: Omit) => - getEventsById({repository: this.repository, ...options}) + getEventsById = (options: Omit) => + getEventsById({...options, repository: this.repository}) - deriveEventsById = (options: Omit) => - deriveEventsById({repository: this.repository, ...options}) + deriveEventsById = (options: Omit) => + deriveEventsById({...options, repository: this.repository}) - deriveEvents = (options: Omit) => - deriveEvents({repository: this.repository, ...options}) + deriveEvents = (options: Omit) => + deriveEvents({...options, repository: this.repository}) - makeDeriveEvent = (options: Omit) => - makeDeriveEvent({repository: this.repository, ...options}) + makeDeriveEvent = (options: Omit) => + makeDeriveEvent({...options, repository: this.repository}) - getEventsByIdByUrl = (options: Omit) => - getEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options}) + getEventsByIdByUrl = (options: Omit) => + getEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository}) - deriveEventsByIdByUrl = (options: Omit) => - deriveEventsByIdByUrl({tracker: this.tracker, repository: this.repository, ...options}) + deriveEventsByIdByUrl = (options: Omit) => + deriveEventsByIdByUrl({...options, tracker: this.tracker, repository: this.repository}) - getEventsByIdForUrl = (options: Omit) => - getEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options}) + getEventsByIdForUrl = (options: Omit) => + getEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository}) - deriveEventsByIdForUrl = (options: Omit) - deriveEventsByIdForUrl({tracker: this.tracker, repository: this.repository, ...options}) + deriveEventsByIdForUrl = (options: Omit) => + deriveEventsByIdForUrl({...options, tracker: this.tracker, repository: this.repository}) - deriveItemsByKey = (options: Omit, 'repository'>) => - deriveEventsByIdForUrl({repository: this.repository, ...options}) + deriveItemsByKey = (options: Omit, "repository">) => + deriveItemsByKey({...options, repository: this.repository}) deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.repository, event) } diff --git a/packages/client/src/clientData.ts b/packages/client/src/clientData.ts index 6f5821a..63a73bc 100644 --- a/packages/client/src/clientData.ts +++ b/packages/client/src/clientData.ts @@ -1,93 +1,107 @@ -import {Subscriber} from 'svelte/store' -import {call} from '@welshman/lib' +import {writable} from "svelte/store" +import type {Readable, Unsubscriber} from "svelte/store" +import type {Maybe} from "@welshman/lib" +import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store" +import type {MakeLoadItemOptions} from "@welshman/store" +import type {ClientContext} from "./client.js" +/** + * Base class for a reactive, keyed collection of "local" (non-event) data — + * things like relay stats or NIP-11 profiles that aren't backed by the + * repository. The collection owns its own map and is its own Svelte store: its + * `subscribe` emits the underlying `Map`. + * + * Subclasses reach the client through the `ClientContext` seam, never the + * concrete `Client`, so they never create a dependency cycle. + */ export class ClientData { - selfSubscribers: Subscriber>[] = [] - clearSubscribers: Subscriber>[] = [] - itemSubscribers: Subscriber[] = [] - data = new Map() + protected index = writable(new Map()) + protected getIndex = getter(this.index) + protected itemSubscribers: ((key: string, value: Maybe) => void)[] = [] + public derive: (key?: string, ...args: any[]) => Readable> - // Svelte store-like methods - - private notify = () => { - for (const subscriber of this.selfSubscribers) { - subscriber(this) - } + constructor(protected readonly ctx: ClientContext) { + this.derive = makeDeriveItem(this.index) } - subscribe = (subscriber: Subscriber>) => { - subscriber(this) - this.selfSubscribers.push(subscriber) + subscribe = this.index.subscribe - return () => { - this.selfSubscribers.splice(this.selfSubscribers.indexOf(subscriber), 1) - } - } + get = (key: string): Maybe => this.getIndex().get(key) - derived = (key: string) => { - return readable(this.get(key), set => { - const subscribers = [ - this.onClear(() => set(undefined)), - this.onItem(set) - ] + getAll = (): T[] => Array.from(this.getIndex().values()) - return () => subscribers.forEach(call) - }) - } + keys = () => this.getIndex().keys() - // EventEmitter-like methods - - private emitClear = () => { - for (const subscriber of this.clearSubscribers) { - subscriber(this) - } - - this.notify() - } - - onClear = (subscriber: Subscriber) => { - this.clearSubscribers.push(subscriber) - - return () => { - this.clearSubscribers.splice(this.clearSubscribers.indexOf(subscriber), 1) - } - } - - private emitItem = (item: T) => { - for (const subscriber of this.itemSubscribers) { - subscriber(item) - } - - this.notify() - } - - onItem = (subscriber: Subscriber) => { - this.itemSubscribers.push(subscriber) - - return () => { - this.itemSubscribers.splice(this.itemSubscribers.indexOf(subscriber), 1) - } - } - - // Map-like methods - - clear = () => { - this.data.clear() - this.emitClear() - } - - delete = (key: string) => { - this.data.delete(key) - this.emitItem(key, undefined) - } + values = () => this.getIndex().values() set = (key: string, value: T) => { - this.data.set(key, value) + this.index.update($items => { + $items.set(key, value) + + return $items + }) + this.emitItem(key, value) } - get = (key: string) => this.data.get(key) - values = () => this.data.values() - items = () => this.data.items() - keys = () => this.data.keys() + delete = (key: string) => { + this.index.update($items => { + $items.delete(key) + + return $items + }) + + this.emitItem(key, undefined) + } + + clear = () => { + const keys = Array.from(this.getIndex().keys()) + + this.index.set(new Map()) + + for (const key of keys) { + this.emitItem(key, undefined) + } + } + + onItem = (subscriber: (key: string, value: Maybe) => void): Unsubscriber => { + this.itemSubscribers.push(subscriber) + + return () => { + const i = this.itemSubscribers.indexOf(subscriber) + + if (i !== -1) this.itemSubscribers.splice(i, 1) + } + } + + protected emitItem = (key: string, value: Maybe) => { + for (const subscriber of this.itemSubscribers) { + subscriber(key, value) + } + } +} + +/** + * A `ClientData` collection that knows how to lazily load items by key from the + * network. Subclasses implement `fetch`; `load`/`forceLoad`/`derive` are derived + * from it (with per-key caching and backoff via `makeLoadItem`). + */ +export abstract class LoadableData extends ClientData { + load: (key: string, ...args: any[]) => Promise> + forceLoad: (key: string, ...args: any[]) => Promise> + + abstract fetch(key: string, ...args: any[]): Promise + + constructor(ctx: ClientContext, options: MakeLoadItemOptions = {}) { + super(ctx) + + // Subclasses implement `fetch` as an arrow field, whose initializer runs + // *after* super() — so `this.fetch` is undefined here. makeLoadItem captures + // its loadItem eagerly, so we defer the lookup to call time via this wrapper. + const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args) + + this.load = makeLoadItem(fetch, this.get, options) + this.forceLoad = makeForceLoadItem(fetch, this.get) + this.derive = makeDeriveItem(this.index, this.load) + } } diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts new file mode 100644 index 0000000..8e99ba9 --- /dev/null +++ b/packages/client/src/commands.ts @@ -0,0 +1,373 @@ +import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib" +import { + sendManagementRequest, + addToListPublicly, + addToListPrivately, + updateList, + removeFromList, + makeHttpAuth, + getListTags, + getRelayTags, + getRelayTagValues, + getRelaysFromList, + makeList, + makeRoomCreateEvent, + makeRoomDeleteEvent, + makeRoomEditEvent, + makeRoomJoinEvent, + makeRoomLeaveEvent, + makeRoomAddMemberEvent, + makeRoomRemoveMemberEvent, + isPublishedProfile, + createProfile, + editProfile, + RelayMode, + makeEvent, + MESSAGING_RELAYS, + BLOCKED_RELAYS, + SEARCH_RELAYS, + FOLLOWS, + RELAYS, + MUTES, + PINS, + prep, +} from "@welshman/util" +import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util" +import {addMaximalFallbacks} from "./router.js" +import type {Router} from "./router.js" +import {MergedThunk, publishThunk} from "./thunk.js" +import type {ThunkOptions} from "./thunk.js" +import type {ClientContext} from "./client.js" +import type {User} from "./user.js" +import type {RelayLists} from "./relayLists.js" +import type {MessagingRelayLists} from "./messagingRelayLists.js" +import type {BlockedRelayLists} from "./blockedRelayLists.js" +import type {SearchRelayLists} from "./searchRelayLists.js" +import type {FollowLists} from "./follows.js" +import type {MuteLists} from "./mutes.js" +import type {PinLists} from "./pins.js" + +export type CommandsDeps = { + client: ClientContext + user: User + router: Router + relayLists: RelayLists + messagingRelayLists: MessagingRelayLists + blockedRelayLists: BlockedRelayLists + searchRelayLists: SearchRelayLists + followLists: FollowLists + muteLists: MuteLists + pinLists: PinLists +} + +export type SendWrappedOptions = Omit< + ThunkOptions, + "event" | "relays" | "recipient" | "client" | "user" +> & { + event: EventTemplate + recipients: string[] +} + +/** + * The high-level "do an action" API: each method builds an event for the + * client's user and publishes it via a thunk. Replaces the old module of global + * functions; everything that used a global (current pubkey, signer, router, the + * user's lists) is now injected. + */ +export class Commands { + readonly client: ClientContext + readonly user: User + readonly router: Router + readonly relayLists: RelayLists + readonly messagingRelayLists: MessagingRelayLists + readonly blockedRelayLists: BlockedRelayLists + readonly searchRelayLists: SearchRelayLists + readonly followLists: FollowLists + readonly muteLists: MuteLists + readonly pinLists: PinLists + + constructor(deps: CommandsDeps) { + this.client = deps.client + this.user = deps.user + this.router = deps.router + this.relayLists = deps.relayLists + this.messagingRelayLists = deps.messagingRelayLists + this.blockedRelayLists = deps.blockedRelayLists + this.searchRelayLists = deps.searchRelayLists + this.followLists = deps.followLists + this.muteLists = deps.muteLists + this.pinLists = deps.pinLists + } + + private publish = (options: Omit) => + publishThunk({...options, client: this.client, user: this.user}) + + private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls() + + private encryptToSelf = (payload: string) => this.user.nip44EncryptToSelf(payload) + + // NIP 65 + + removeRelay = async (url: string, mode: RelayMode) => { + const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) + const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) + const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read + const tags = list.publicTags.filter(nthNe(1, url)) + + // If we had a duplicate that was used as the alt mode, keep the alt + if (dup && (!dup[2] || dup[2] === alt)) { + tags.push(["r", url, alt]) + } + + const event = {kind: list.kind, content: list.event?.content || "", tags} + const relays = this.fromUser() + + // Make sure to notify the old relay too + relays.push(url) + + return this.publish({event, relays}) + } + + addRelay = async (url: string, mode: RelayMode) => { + const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) + const dup = getRelayTags(getListTags(list)).find(nthEq(1, url)) + const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode]) + const tags = [...list.publicTags.filter(nthNe(1, url)), tag] + const event = {kind: list.kind, content: list.event?.content || "", tags} + + return this.publish({event, relays: this.fromUser()}) + } + + setRelays = async (tags: string[][]) => { + const event = makeEvent(RELAYS, {tags}) + const relays = this.router + .merge([this.router.Index(), this.router.FromRelays(getRelayTagValues(tags))]) + .getUrls() + + return this.publish({event, relays}) + } + + setReadRelays = async (urls: string[]) => { + const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) + const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1)) + const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write]) + const readTags = urls.map(url => ["r", url, RelayMode.Read]) + const tags = [...writeTags, ...readTags] + const event = {kind: list.kind, content: list.event?.content || "", tags} + + return this.publish({event, relays: this.fromUser()}) + } + + setWriteRelays = async (urls: string[]) => { + const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS}) + const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1)) + const readTags = readRelays.map(url => ["r", url, RelayMode.Read]) + const writeTags = urls.map(url => ["r", url, RelayMode.Write]) + const tags = [...readTags, ...writeTags] + const event = {kind: list.kind, content: list.event?.content || "", tags} + + return this.publish({event, relays: this.fromUser()}) + } + + // NIP 17 + + removeMessagingRelay = async (url: string) => { + const list = + (await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: MESSAGING_RELAYS}) + const event = await removeFromList(list, url).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + addMessagingRelay = async (url: string) => { + const list = + (await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: MESSAGING_RELAYS}) + const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + setMessagingRelays = async (urls: string[]) => { + const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])}) + + return this.publish({event, relays: this.router.FromUser().getUrls()}) + } + + // Blocked Relays + + removeBlockedRelay = async (url: string) => { + const list = + (await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: BLOCKED_RELAYS}) + const event = await removeFromList(list, url).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + addBlockedRelay = async (url: string) => { + const list = + (await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: BLOCKED_RELAYS}) + const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + setBlockedRelays = async (urls: string[]) => { + const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])}) + + return this.publish({event, relays: this.router.FromUser().getUrls()}) + } + + // Search Relays + + removeSearchRelay = async (url: string) => { + const list = + (await this.searchRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: SEARCH_RELAYS}) + const event = await removeFromList(list, url).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + addSearchRelay = async (url: string) => { + const list = + (await this.searchRelayLists.forceLoad(this.user.pubkey, [])) || + makeList({kind: SEARCH_RELAYS}) + const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + setSearchRelays = async (urls: string[]) => { + const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])}) + + return this.publish({event, relays: this.router.FromUser().getUrls()}) + } + + // NIP 01 + + setProfile = (profile: Profile) => { + const relays = this.router.merge([this.router.Index(), this.router.FromUser()]).getUrls() + const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) + + return this.publish({event, relays}) + } + + // NIP 02 + + unfollow = async (value: string) => { + const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) + const event = await removeFromList(list, value).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + follow = async (tag: string[]) => { + const list = (await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS}) + const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + unmute = async (value: string) => { + const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES}) + const event = await removeFromList(list, value).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + mutePublicly = async (tag: string[]) => { + const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES}) + const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + mutePrivately = async (tag: string[]) => { + const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES}) + const event = await addToListPrivately(list, tag).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + setMutes = async ({ + publicTags, + privateTags, + }: { + publicTags?: string[][] + privateTags?: string[][] + }) => { + const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES}) + const event = await updateList(list, {publicTags, privateTags}).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + unpin = async (value: string) => { + const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS}) + const event = await removeFromList(list, value).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + pin = async (tag: string[]) => { + const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS}) + const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf) + + return this.publish({event, relays: this.fromUser()}) + } + + // NIP 59 + + sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => { + // Stabilize the event id across the different wraps + const stableEvent = prep(event, this.user.pubkey, now()) + + return new MergedThunk( + await Promise.all( + uniq(recipients).map(async recipient => { + const relays = getRelaysFromList(await this.messagingRelayLists.load(recipient)) + + return this.publish({event: stableEvent, relays, recipient, ...options}) + }), + ), + ) + } + + // NIP 86 + + manageRelay = async (url: string, request: ManagementRequest) => { + url = url.replace(/^ws/, "http") + + const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request)) + const authEvent = await this.user.sign(authTemplate) + + return sendManagementRequest(url, request, authEvent) + } + + // NIP 29 + + createRoom = (url: string, room: RoomMeta) => + this.publish({event: makeRoomCreateEvent(room), relays: [url]}) + + deleteRoom = (url: string, room: RoomMeta) => + this.publish({event: makeRoomDeleteEvent(room), relays: [url]}) + + editRoom = (url: string, room: RoomMeta) => + this.publish({event: makeRoomEditEvent(room), relays: [url]}) + + joinRoom = (url: string, room: RoomMeta) => + this.publish({event: makeRoomJoinEvent(room), relays: [url]}) + + leaveRoom = (url: string, room: RoomMeta) => + this.publish({event: makeRoomLeaveEvent(room), relays: [url]}) + + addRoomMember = (url: string, room: RoomMeta, pubkey: string) => + this.publish({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]}) + + removeRoomMember = (url: string, room: RoomMeta, pubkey: string) => + this.publish({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]}) +} diff --git a/packages/client/src/createApp.ts b/packages/client/src/createApp.ts new file mode 100644 index 0000000..b0d9f6c --- /dev/null +++ b/packages/client/src/createApp.ts @@ -0,0 +1,141 @@ +import {Client} from "./client.js" +import type {ClientOptions} from "./client.js" +import {Router} from "./router.js" +import type {RouterOptions} from "./router.js" +import {RelayStats} from "./relayStats.js" +import {RelayLists} from "./relayLists.js" +import {BlockedRelayLists} from "./blockedRelayLists.js" +import {Relays} from "./relays.js" +import {Plaintext} from "./plaintext.js" +import {Profiles} from "./profiles.js" +import {FollowLists} from "./follows.js" +import {MuteLists} from "./mutes.js" +import {PinLists} from "./pins.js" +import {BlossomServerLists} from "./blossom.js" +import {MessagingRelayLists} from "./messagingRelayLists.js" +import {SearchRelayLists} from "./searchRelayLists.js" +import {Handles} from "./handles.js" +import {Zappers} from "./zappers.js" +import {Topics} from "./topics.js" +import {Tags} from "./tags.js" +import {Wot} from "./wot.js" +import {Feeds} from "./feeds.js" +import {Searches} from "./search.js" +import {Sync} from "./sync.js" +import {GiftWraps} from "./giftWraps.js" +import {Commands} from "./commands.js" + +export type AppOptions = ClientOptions & { + dufflepudUrl?: string + // Whether to unwrap incoming NIP-59 gift wraps (DMs) for this client's user. + shouldUnwrap?: boolean + // The router's data dependencies are wired up below, so callers only supply + // the configuration knobs. + router?: Omit +} + +/** + * Composes a default application instance: a `Client` plus the core data + * modules, wired together. This is where genuine domain cycles (Router <-> + * RelayLists, RelayStats <-> BlockedRelayLists) are broken — modules are given + * lazily-resolved closures that reach their siblings at call time, never at + * construction time. + * + * Callers who want a different module set can ignore this helper and compose + * their own bag directly, or spread additional modules onto the result. + */ +export const createApp = (options: AppOptions = {}) => { + const client = new Client(options) + const relays = new Relays(client) + const plaintext = new Plaintext(client) + + // Declared up-front so the lazily-invoked closures below can reach them. None + // are called during construction, only at routing/scoring time. + let relayLists: RelayLists + let blockedRelayLists: BlockedRelayLists + + const relayStats = new RelayStats(client, { + isRelayBlocked: url => { + const pubkey = client.user?.pubkey + + return pubkey ? blockedRelayLists.getBlockedRelays(pubkey).includes(url) : false + }, + }) + + const router = new Router(client, { + ...options.router, + getRelaysForPubkey: (pubkey, mode) => relayLists.getRelaysForPubkey(pubkey, mode), + getRelayQuality: url => relayStats.getQuality(url), + }) + + relayLists = new RelayLists(client, router) + blockedRelayLists = new BlockedRelayLists(client, relayLists) + + const profiles = new Profiles(client, relayLists) + const followLists = new FollowLists(client, relayLists) + const muteLists = new MuteLists(client, relayLists, plaintext) + const pinLists = new PinLists(client, relayLists) + const blossomServerLists = new BlossomServerLists(client, relayLists) + const messagingRelayLists = new MessagingRelayLists(client, relayLists) + const searchRelayLists = new SearchRelayLists(client, relayLists) + const handles = new Handles(client, profiles, {dufflepudUrl: options.dufflepudUrl}) + const zappers = new Zappers(client, profiles, {dufflepudUrl: options.dufflepudUrl}) + const topics = new Topics(client) + const tags = new Tags(client, router, profiles) + const wot = new Wot(client, followLists, muteLists) + const feeds = new Feeds(client, wot) + const searches = new Searches(client, router, profiles, topics, relays, handles, wot) + const sync = new Sync(client, relays) + const giftWraps = new GiftWraps(client, {shouldUnwrap: options.shouldUnwrap}) + + // Commands act on behalf of a signed-in user, so they're only available when + // the client has one. + const commands = client.user + ? new Commands({ + client, + user: client.user, + router, + relayLists, + messagingRelayLists, + blockedRelayLists, + searchRelayLists, + followLists, + muteLists, + pinLists, + }) + : undefined + + return { + client, + router, + relays, + plaintext, + relayStats, + relayLists, + blockedRelayLists, + profiles, + followLists, + muteLists, + pinLists, + blossomServerLists, + messagingRelayLists, + searchRelayLists, + handles, + zappers, + topics, + tags, + wot, + feeds, + searches, + sync, + giftWraps, + commands, + cleanup: () => { + relayStats.cleanup() + giftWraps.cleanup() + client.cleanup() + }, + } +} + +export type App = ReturnType diff --git a/packages/client/src/feeds.ts b/packages/client/src/feeds.ts new file mode 100644 index 0000000..93c72f5 --- /dev/null +++ b/packages/client/src/feeds.ts @@ -0,0 +1,70 @@ +import {Scope, FeedController} from "@welshman/feeds" +import type {FeedControllerOptions, Feed} from "@welshman/feeds" +import type {AdapterContext} from "@welshman/net" +import type {ClientContext} from "./client.js" +import type {Wot} from "./wot.js" + +export type MakeFeedControllerOptions = Partial> & {feed: Feed} + +/** + * Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is + * delegated to the injected `Wot`, and feeds fetch through THIS client's net + * context (pool + repository) rather than the global one. + */ +export class Feeds { + constructor( + readonly ctx: ClientContext, + readonly wot: Wot, + ) {} + + getPubkeysForScope = (scope: Scope): string[] => { + const $pubkey = this.ctx.user?.pubkey + + if (!$pubkey) { + return [] + } + + switch (scope) { + case Scope.Self: + return [$pubkey] + case Scope.Follows: + return this.wot.getFollows($pubkey) + case Scope.Network: + return this.wot.getNetwork($pubkey) + case Scope.Followers: + return this.wot.getFollowers($pubkey) + default: + return [] + } + } + + getPubkeysForWOTRange = (min: number, max: number): string[] => { + const pubkeys = [] + const $maxWot = this.wot.getMaxWot() ?? 0 + const thresholdMin = $maxWot * min + const thresholdMax = $maxWot * max + + for (const [tpk, score] of this.wot.getWotGraph().entries()) { + if (score >= thresholdMin && score <= thresholdMax) { + pubkeys.push(tpk) + } + } + + return pubkeys + } + + // The net seam: route feed requests through this client's pool/repository so + // feeds fetch through THIS client rather than the global net context. + get netContext(): AdapterContext { + return {pool: this.ctx.pool, repository: this.ctx.repository} + } + + makeFeedController = (options: MakeFeedControllerOptions) => + new FeedController({ + getPubkeysForScope: this.getPubkeysForScope, + getPubkeysForWOTRange: this.getPubkeysForWOTRange, + signer: this.ctx.user?.signer, + context: this.netContext, + ...options, + }) +} diff --git a/packages/client/src/follows.ts b/packages/client/src/follows.ts new file mode 100644 index 0000000..54d2021 --- /dev/null +++ b/packages/client/src/follows.ts @@ -0,0 +1,26 @@ +import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the + * author's write relays), so it depends on the relay-list collection. + */ +export class FollowLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [FOLLOWS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: followList => followList.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(FOLLOWS)(pubkey, relayHints) + } +} diff --git a/packages/client/src/giftWraps.ts b/packages/client/src/giftWraps.ts new file mode 100644 index 0000000..9d9c3f0 --- /dev/null +++ b/packages/client/src/giftWraps.ts @@ -0,0 +1,78 @@ +import {get, writable} from "svelte/store" +import type {Unsubscriber} from "svelte/store" +import {on, TaskQueue} from "@welshman/lib" +import {WRAP, getPubkeyTagValues} from "@welshman/util" +import type {TrustedEvent, SignedEvent} from "@welshman/util" +import {Nip59} from "@welshman/signer" +import type {ClientContext} from "./client.js" + +export type GiftWrapsOptions = { + shouldUnwrap?: boolean +} + +/** + * Per-client gift-wrap (NIP-59) ingestion. Watches the client's repository for + * kind-1059 wraps and unwraps the ones addressed to THIS client's user, storing + * the resulting rumors via the wrap manager. + * + * In the old global model a single queue tried every logged-in account's signer + * 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 { + shouldUnwrap = writable(false) + failedUnwraps = new Set() + queue: TaskQueue + cleanup: Unsubscriber + + constructor( + readonly ctx: ClientContext, + options: GiftWrapsOptions = {}, + ) { + this.shouldUnwrap.set(options.shouldUnwrap ?? false) + + this.queue = new TaskQueue({ + batchSize: 5, + batchDelay: 30, + processItem: async (wrap: TrustedEvent) => { + const signer = this.ctx.user?.signer + const recipient = this.ctx.user?.pubkey + + // Only unwrap messages addressed to our user + if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) { + return + } + + try { + const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent) + + this.ctx.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient}) + } catch (e) { + this.failedUnwraps.add(wrap.id) + } + }, + }) + + // Process wraps already in the repository, then any that arrive later + for (const wrap of this.ctx.repository.query([{kinds: [WRAP]}])) { + this.enqueue(wrap) + } + + this.cleanup = on(this.ctx.repository, "update", ({added}: {added: TrustedEvent[]}) => { + for (const event of added) { + if (event.kind === WRAP) { + this.enqueue(event) + } + } + }) + } + + enqueue = (wrap: TrustedEvent) => { + if (!get(this.shouldUnwrap)) return + if (this.failedUnwraps.has(wrap.id)) return + if (this.ctx.wrapManager.getRumor(wrap.id)) return + + this.queue.push(wrap) + } +} diff --git a/packages/client/src/handles.ts b/packages/client/src/handles.ts new file mode 100644 index 0000000..7443abf --- /dev/null +++ b/packages/client/src/handles.ts @@ -0,0 +1,125 @@ +import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {deriveDeduplicated} from "@welshman/store" +import {LoadableData} from "./clientData.js" +import type {ClientContext} from "./client.js" +import type {Profiles} from "./profiles.js" + +export type Handle = { + nip05: string + pubkey?: string + nip46?: string[] + relays?: string[] +} + +export async function queryProfile(nip05: string): Promise> { + const parts = nip05.split("@") + const name = parts.length > 1 ? parts[0] : "_" + const domain = last(parts) + + try { + const { + names, + relays = {}, + nip46 = {}, + } = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`) + + const pubkey = names[name] + + if (!pubkey) { + return undefined + } + + return { + nip05, + pubkey, + nip46: nip46[pubkey], + relays: relays[pubkey], + } + } catch (_e) { + return undefined + } +} + +export const displayNip05 = (nip05: string) => + nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05 + +export const displayHandle = (handle: Handle) => displayNip05(handle.nip05) + +/** + * NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection: + * items aren't nostr events, they're fetched over HTTP (either directly from + * each domain's `.well-known/nostr.json`, or via a dufflepud proxy to protect + * user privacy). Depends on the profiles collection to resolve a pubkey's + * handle. + */ +export class Handles extends LoadableData { + constructor( + ctx: ClientContext, + readonly profiles: Profiles, + readonly options: {dufflepudUrl?: string} = {}, + ) { + super(ctx) + } + + fetch = batcher(800, async (nip05s: string[]) => { + const result = new Map() + + // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly + if (this.options.dufflepudUrl) { + const res: any = await tryCatch( + async () => await postJson(`${this.options.dufflepudUrl}/handle/info`, {handles: nip05s}), + ) + + for (const {handle: nip05, info} of res?.data || []) { + if (info) { + result.set(nip05, {...info, nip05}) + } + } + } else { + const results = await Promise.all( + nip05s.map(async nip05 => ({ + nip05, + info: await tryCatch(async () => await queryProfile(nip05)), + })), + ) + + for (const {nip05, info} of results) { + if (info) { + result.set(nip05, {...info, nip05}) + } + } + } + + for (const [nip05, info] of result) { + this.set(nip05, info) + } + + return nip05s.map(nip05 => result.get(nip05)) + }) + + loadForPubkey = async (pubkey: string, relays: string[] = []) => { + const $profile = await this.profiles.load(pubkey, relays) + + return $profile?.nip05 ? this.load($profile.nip05) : undefined + } + + deriveForPubkey = (pubkey: string, relays: string[] = []) => { + this.loadForPubkey(pubkey, relays) + + return deriveDeduplicated( + [this.index, this.profiles.derive(pubkey, relays)], + ([$handlesByNip05, $profile]) => { + if (!$profile?.nip05) return undefined + + const handle = $handlesByNip05.get($profile.nip05) + + if (handle?.pubkey !== pubkey) return undefined + + return handle + }, + ) + } + + display = (nip05: string) => displayNip05(nip05) +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c87ba33..0bda879 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,30 @@ -export * from "./thunk.ts" -export * from "./client.ts" -export * from "./relays.ts" +export * from "./client.js" +export * from "./clientData.js" +export * from "./repositoryCollection.js" +export * from "./user.js" +export * from "./router.js" +export * from "./relays.js" +export * from "./relayStats.js" +export * from "./relayLists.js" +export * from "./blockedRelayLists.js" +export * from "./plaintext.js" +export * from "./profiles.js" +export * from "./follows.js" +export * from "./mutes.js" +export * from "./pins.js" +export * from "./blossom.js" +export * from "./messagingRelayLists.js" +export * from "./searchRelayLists.js" +export * from "./handles.js" +export * from "./zappers.js" +export * from "./topics.js" +export * from "./tags.js" +export * from "./session.js" +export * from "./wot.js" +export * from "./feeds.js" +export * from "./search.js" +export * from "./sync.js" +export * from "./giftWraps.js" +export * from "./commands.js" +export * from "./thunk.js" +export * from "./createApp.js" diff --git a/packages/client/src/messagingRelayLists.ts b/packages/client/src/messagingRelayLists.ts new file mode 100644 index 0000000..3e08fd3 --- /dev/null +++ b/packages/client/src/messagingRelayLists.ts @@ -0,0 +1,27 @@ +import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the + * outbox model (the author's write relays), so it depends on the relay-list + * collection. + */ +export class MessagingRelayLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [MESSAGING_RELAYS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: list => list.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints) + } +} diff --git a/packages/client/src/mutes.ts b/packages/client/src/mutes.ts new file mode 100644 index 0000000..fb1b8f4 --- /dev/null +++ b/packages/client/src/mutes.ts @@ -0,0 +1,42 @@ +import {MUTES, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent, PublishedList} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" +import type {Plaintext} from "./plaintext.js" + +/** + * Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in + * encrypted content, so decoding goes through the plaintext cache. + */ +export class MuteLists extends RepositoryCollection { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + readonly plaintext: Plaintext, + ) { + super(ctx, { + filters: [{kinds: [MUTES]}], + eventToItem: async (event: TrustedEvent) => { + const content = await plaintext.ensure(event) + + // If this is our own mute list but it couldn't be decrypted yet because + // no signer is available, don't cache a result with empty private tags — + // that would get stuck permanently since the repository view won't + // re-process an already-seen event id. Returning undefined leaves it + // uncached so it's retried once a signer is available. For other + // pubkeys' lists we fall through and read just the public tags. + if (event.content && content === undefined && event.pubkey === ctx.user?.pubkey) { + return undefined + } + + return readList(asDecryptedEvent(event, {content})) + }, + getKey: mute => mute.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(MUTES)(pubkey, relayHints) + } +} diff --git a/packages/client/src/pins.ts b/packages/client/src/pins.ts new file mode 100644 index 0000000..c6c3e7c --- /dev/null +++ b/packages/client/src/pins.ts @@ -0,0 +1,26 @@ +import {PINS, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model + * (the author's write relays), so it depends on the relay-list collection. + */ +export class PinLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [PINS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: pins => pins.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(PINS)(pubkey, relayHints) + } +} diff --git a/packages/client/src/plaintext.ts b/packages/client/src/plaintext.ts new file mode 100644 index 0000000..501c5b9 --- /dev/null +++ b/packages/client/src/plaintext.ts @@ -0,0 +1,42 @@ +import {decrypt} from "@welshman/signer" +import type {Maybe} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {ClientData} from "./clientData.js" + +/** + * A cache of decrypted event content, keyed by event id. + * + * In the old global model decryption used `getSigner(getSession(event.pubkey))` + * — whichever logged-in account authored the event. In the per-client model + * there is exactly one identity, so this reduces to "is this our user?". That + * scoping is also what keeps decrypted content (including DM rumors) from + * bleeding across identities — each client decrypts only its own. + */ +export class Plaintext extends ClientData { + ensure = async (event: TrustedEvent): Promise> => { + // Check for key presence rather than truthiness so a legitimately empty + // decrypted result ("") is treated as cached and we don't re-hit the signer + // on every call. + if (event.content && this.get(event.id) === undefined) { + const signer = event.pubkey === this.ctx.user?.pubkey ? this.ctx.user?.signer : undefined + + if (!signer) return + + let result + + try { + result = await decrypt(signer, event.pubkey, event.content) + } catch (e: any) { + if (!String(e).match(/invalid base64/)) { + throw e + } + } + + if (result !== undefined) { + this.set(event.id, result) + } + } + + return this.get(event.id) + } +} diff --git a/packages/client/src/profiles.ts b/packages/client/src/profiles.ts new file mode 100644 index 0000000..58856e2 --- /dev/null +++ b/packages/client/src/profiles.ts @@ -0,0 +1,36 @@ +import {derived, readable} from "svelte/store" +import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's + * write relays), so it depends on the relay-list collection. + */ +export class Profiles extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [PROFILE]}], + eventToItem: readProfile, + getKey: profile => profile.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(PROFILE)(pubkey, relayHints) + } + + display = (pubkey: string | undefined) => + pubkey ? displayProfile(this.get(pubkey), displayPubkey(pubkey)) : "" + + deriveDisplay = (pubkey: string | undefined, ...args: any[]) => + pubkey + ? derived(this.derive(pubkey, ...args), $profile => + displayProfile($profile, displayPubkey(pubkey)), + ) + : readable("") +} diff --git a/packages/client/src/relayLists.ts b/packages/client/src/relayLists.ts index a0d01be..fbfe093 100644 --- a/packages/client/src/relayLists.ts +++ b/packages/client/src/relayLists.ts @@ -1,67 +1,59 @@ import {chunk, first} from "@welshman/lib" import { RELAYS, + RelayMode, asDecryptedEvent, readList, - TrustedEvent, - sortEventsDesc, getRelaysFromList, - RelayMode, - Filter, isPlainReplaceableKind, + sortEventsDesc, } from "@welshman/util" -import { - deriveItemsByKey, - deriveItems, - makeForceLoadItem, - makeLoadItem, - makeDeriveItem, - getter, -} from "@welshman/store" -import {load} from "@welshman/net" -import {Router, addMinimalFallbacks} from "@welshman/router" -import type {Client} from './client.ts' +import type {Filter, TrustedEvent, PublishedList} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import {addMinimalFallbacks} from "./router.js" +import type {Router} from "./router.js" +import type {ClientContext} from "./client.js" -export class RelayLists extends ClientData { - constructor(readonly client: Client) {} +/** + * NIP-65 relay lists, keyed by pubkey. This is the routing substrate every + * other outbox-model load depends on, so it also exposes `loadUsingOutbox` / + * `makeOutboxLoader` for other collections to build their fetchers on. + * + * It depends on a `Router`, and the `Router` depends (via injected functions) on + * this collection — a genuine domain cycle that `createApp` breaks by wiring the + * router's `getRelaysForPubkey` to a lazily-resolved closure. + */ +export class RelayLists extends RepositoryCollection { + constructor( + ctx: ClientContext, + readonly router: Router, + ) { + super(ctx, { + filters: [{kinds: [RELAYS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: (list: PublishedList) => list.event.pubkey, + }) + } - fetch = async (pubkey: string, relayHints: string[] = []) => { + fetch(pubkey: string, relayHints: string[] = []) { const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}] - await Promise.all([ - this.client.load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}), - this.client.load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}), - this.client.load({filters, relays: Router.get().Index().getUrls()}), + return Promise.all([ + this.ctx.load({filters, relays: this.router.FromRelays(relayHints).getUrls()}), + this.ctx.load({filters, relays: this.router.FromPubkey(pubkey).getUrls()}), + this.ctx.load({filters, relays: this.router.Index().getUrls()}), ]) } - export const relayListsByPubkey = deriveItemsByKey({ - repository, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), - filters: [{kinds: [RELAYS]}], - getKey: relayList => relayList.event.pubkey, - }) + getRelaysForPubkey = (pubkey: string, mode?: RelayMode) => + getRelaysFromList(this.get(pubkey), mode) - export const relayLists = deriveItems(relayListsByPubkey) + // Load a pubkey's events using their advertised write relays (outbox model) - export const getRelayListsByPubkey = getter(relayListsByPubkey) - - export const getRelayLists = getter(relayLists) - - export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey) - - export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList) - - export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList) - - export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList) - - // Outbox loader - - export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => { - const filters = [{...filter, kinds: [kind], authors: [pubkey]}] - const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write) - const allRelays = Router.get() + loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => { + const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] + const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write) + const allRelays = this.router .FromRelays(writeRelays) .policy(addMinimalFallbacks) .limit(8) @@ -72,7 +64,7 @@ export class RelayLists extends ClientData { } for (const relays of chunk(2, allRelays)) { - const events = await load({filters, relays}) + const events = await this.ctx.load({filters, relays}) if (events.length > 0) { return first(sortEventsDesc(events)) @@ -80,12 +72,15 @@ export class RelayLists extends ClientData { } } - export const makeOutboxLoader = - (kind: number, filter: Filter = {}, limit = 1) => + makeOutboxLoader = + (kind: number, filter: Filter = {}) => async (pubkey: string, relayHints: string[] = []) => { - const filters = [{...filter, kinds: [kind], authors: [pubkey]}] - const relays = Router.get().FromRelays(relayHints).getUrls() + const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}] + const relays = this.router.FromRelays(relayHints).getUrls() - await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)]) + await Promise.all([ + this.ctx.load({filters, relays}), + this.loadUsingOutbox(kind, pubkey, filter), + ]) } } diff --git a/packages/client/src/relayStats.ts b/packages/client/src/relayStats.ts index e380a47..d92c106 100644 --- a/packages/client/src/relayStats.ts +++ b/packages/client/src/relayStats.ts @@ -1,11 +1,10 @@ -import {writable, Subscriber} from "svelte/store" -import {getter, makeDeriveItem} from "@welshman/store" +import type {Unsubscriber} from "svelte/store" import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib" -import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util" -import {Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net" -import {getBlockedRelayList} from "./blockedRelayLists.js" -import type {Client} from "./client.js" - +import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util" +import {SocketStatus, SocketEvent} from "@welshman/net" +import type {Socket, ClientMessage, RelayMessage} from "@welshman/net" +import {ClientData} from "./clientData.js" +import type {ClientContext} from "./client.js" export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void] @@ -53,11 +52,27 @@ export const makeRelayStatsItem = (url: string): RelayStatsItem => ({ notice_count: 0, }) +export type RelayStatsOptions = { + // Allows a host app to zero out blocked relays once a blocked-relay-list + // module is composed in, without RelayStats depending on it. + isRelayBlocked?: (url: string) => boolean +} + +/** + * Tracks per-relay connection statistics by listening to socket activity on the + * client's pool, and exposes a `getQuality` heuristic the router uses to rank + * relays. A "local" collection — its data isn't backed by the repository. + */ export class RelayStats extends ClientData { cleanup: Unsubscriber - constructor(readonly client: Client) { - this.cleanup = client.pool.subscribe(socket => { + 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) @@ -70,14 +85,50 @@ export class RelayStats extends ClientData { }) } + getQuality = (url: string) => { + // Skip non-relays entirely + if (!isRelayUrl(url)) return 0 + + // Skip blocked relays (when a host app provides the check) + if (this.statsOptions.isRelayBlocked?.(url)) return 0 + + const stats = this.get(url) + + // If we have recent errors, skip it + if (stats) { + if (stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0 + if (stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0 + if (stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0 + } + + // Prefer stuff we're connected to + if (this.ctx.pool.has(url)) return 1 + + // Prefer stuff we've connected to in the past + if (stats) return 0.9 + + // If it's not a weird url give it an ok score + if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) { + return 0.8 + } + + // Default to a "meh" score + return 0.7 + } + // Utilities for syncing stats from connections to relays private updateRelayStats = batch(150, (batched: RelayStatsUpdate[]) => { for (const [url, updates] of groupBy(([url]) => url, batched)) { + if (!url || !isRelayUrl(url)) { + console.warn(`Attempted to update stats for an invalid relay url: ${url}`) + continue + } + const prev = this.get(url) const next = prev ? {...prev} : makeRelayStatsItem(url) - for (const [_, update] of updates) { + for (const [, update] of updates) { update(next) } @@ -107,7 +158,7 @@ export class RelayStats extends ClientData { private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => { if (verb === "OK") { - const [_, ok] = extra + const [, ok] = extra this.updateRelayStats([ url, diff --git a/packages/client/src/relays.ts b/packages/client/src/relays.ts new file mode 100644 index 0000000..4c8ffed --- /dev/null +++ b/packages/client/src/relays.ts @@ -0,0 +1,43 @@ +import {derived} from "svelte/store" +import {fetchJson} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {displayRelayUrl, displayRelayProfile} from "@welshman/util" +import type {RelayProfile} from "@welshman/util" +import {LoadableData} from "./clientData.js" + +/** + * NIP-11 relay profiles, keyed by url. A "local" loadable collection: items + * aren't nostr events, they're fetched over HTTP from each relay. + */ +export class Relays extends LoadableData { + fetch = async (url: string): Promise> => { + try { + const json = await fetchJson(url.replace(/^ws/, "http"), { + headers: { + Accept: "application/nostr+json", + }, + }) + + if (json) { + const info = {...json, url} as RelayProfile + + if (!Array.isArray(info.supported_nips)) { + info.supported_nips = [] + } + + info.supported_nips = info.supported_nips.map(String) + + this.set(url, info) + + return info + } + } catch (e) { + // pass + } + } + + display = (url: string) => displayRelayProfile(this.get(url), displayRelayUrl(url)) + + deriveDisplay = (url: string) => + derived(this.derive(url), $relay => displayRelayProfile($relay, displayRelayUrl(url))) +} diff --git a/packages/client/src/repositoryCollection.ts b/packages/client/src/repositoryCollection.ts new file mode 100644 index 0000000..518c0b3 --- /dev/null +++ b/packages/client/src/repositoryCollection.ts @@ -0,0 +1,88 @@ +import type {Readable} from "svelte/store" +import type {Maybe} from "@welshman/lib" +import type {Filter} from "@welshman/util" +import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store" +import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store" +import type {ClientContext} from "./client.js" + +export type RepositoryCollectionOptions = { + filters: Filter[] + eventToItem: EventToItem + getKey: (item: T) => string + loadOptions?: MakeLoadItemOptions +} + +/** + * Base class for a reactive, keyed collection of data derived from nostr events. + * The repository is the single source of truth — the collection is a live view + * over `ctx.deriveItemsByKey`, never a duplicated map. Subclasses implement + * `fetch` (how to load an item by key from the network) and pass the + * filters/decoder via `super`. + * + * Like `ClientData`, subclasses depend only on the `ClientContext` seam. + */ +export abstract class RepositoryCollection { + byKey: Readable> + all: Readable + subscribe: Readable>["subscribe"] + get: (key: string) => Maybe + getAll: () => T[] + keys: () => IterableIterator + values: () => IterableIterator + load: (key: string, ...args: any[]) => Promise> + forceLoad: (key: string, ...args: any[]) => Promise> + // Reactive view of a single key that also triggers a load + derive: (key?: string, ...args: any[]) => Readable> + // Reactive view of a single key that does not trigger a load + derived: (key?: string, ...args: any[]) => Readable> + private getByKey: () => ItemsByKey + + abstract fetch(key: string, ...args: any[]): Promise + + constructor( + protected readonly ctx: ClientContext, + options: RepositoryCollectionOptions, + ) { + const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args) + + this.byKey = ctx.deriveItemsByKey({ + filters: options.filters, + eventToItem: options.eventToItem, + getKey: options.getKey, + }) + this.all = deriveItems(this.byKey) + this.subscribe = this.byKey.subscribe + this.getByKey = getter(this.byKey) + this.getAll = getter(this.all) + this.get = (key: string) => this.getByKey().get(key) + this.keys = () => this.getByKey().keys() + this.values = () => this.getByKey().values() + this.load = makeLoadItem(fetch, this.get, options.loadOptions) + this.forceLoad = makeForceLoadItem(fetch, this.get) + this.derive = makeDeriveItem(this.byKey, this.load) + this.derived = makeDeriveItem(this.byKey) + } + + // Convenience views of the current user's own item (replaces the old + // user.ts userProfile/userFollowList/etc. derived stores) + + getForUser = () => { + const pubkey = this.ctx.user?.pubkey + + return pubkey ? this.get(pubkey) : undefined + } + + deriveForUser = (...args: any[]) => this.derive(this.ctx.user?.pubkey, ...args) + + loadForUser = (...args: any[]) => { + const pubkey = this.ctx.user?.pubkey + + return pubkey ? this.load(pubkey, ...args) : Promise.resolve(undefined) + } + + forceLoadForUser = (...args: any[]) => { + const pubkey = this.ctx.user?.pubkey + + return pubkey ? this.forceLoad(pubkey, ...args) : Promise.resolve(undefined) + } +} diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 1bb82ee..8b52770 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -1,41 +1,17 @@ +import {nth, uniq, first, sortBy, shuffle, inc, add, take} from "@welshman/lib" import { - nth, - uniq, - intersection, - mergeLeft, - first, - clamp, - sortBy, - shuffle, - pushToMapKey, - inc, - add, - take, - chunks, -} from "@welshman/lib" -import { - getFilterId, isRelayUrl, isOnionUrl, isLocalUrl, isShareableRelayUrl, - PROFILE, - RELAYS, - MESSAGING_RELAYS, - FOLLOWS, - WRAP, getPubkeyTagValues, normalizeRelayUrl, - TrustedEvent, - Filter, - readList, getAncestorTags, - asDecryptedEvent, - getRelaysFromList, getPubkeyTags, RelayMode, } from "@welshman/util" -import {Repository} from "@welshman/net" +import type {TrustedEvent, Filter} from "@welshman/util" +import type {ClientContext} from "./client.js" export type RelaysAndFilters = { relays: string[] @@ -67,6 +43,21 @@ export type RouterOptions = { * @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 = { @@ -92,18 +83,24 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou // Router class export class Router { - constructor(readonly client: Client, readonly options: RouterOptions) {} + constructor( + readonly ctx: ClientContext, + readonly options: RouterOptions, + ) {} // Utilities derived from options getRelaysForPubkey = (pubkey: string, mode?: RelayMode) => - getRelaysFromList(this.client.relayList.get(pubkey), mode) + this.options.getRelaysForPubkey?.(pubkey, mode) || [] getRelaysForPubkeys = (pubkeys: string[], mode?: RelayMode) => pubkeys.map(pubkey => this.getRelaysForPubkey(pubkey, mode)) - getRelaysForUser = (mode?: RelayMode) => - this.getRelaysForPubkey(this.client.pubkey, mode) + getRelaysForUser = (mode?: RelayMode) => { + const pubkey = this.ctx.user?.pubkey + + return pubkey ? this.getRelaysForPubkey(pubkey, mode) : [] + } // Utilities for creating scenarios @@ -277,7 +274,7 @@ export class RouterScenario { const scoreRelay = (relay: string) => { const weight = relayWeights.get(relay)! - const quality = this.router.client.getRelayQuality(relay) + const quality = this.router.options.getRelayQuality?.(relay) ?? 1 // 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 diff --git a/packages/client/src/search.ts b/packages/client/src/search.ts new file mode 100644 index 0000000..686336a --- /dev/null +++ b/packages/client/src/search.ts @@ -0,0 +1,135 @@ +import Fuse from "fuse.js" +import type {IFuseOptions, FuseResult} from "fuse.js" +import {debounce} from "throttle-debounce" +import {derived} from "svelte/store" +import type {Readable} from "svelte/store" +import {dec, inc, sortBy} from "@welshman/lib" +import {PROFILE} from "@welshman/util" +import type {PublishedProfile, RelayProfile} from "@welshman/util" +import {throttled, deriveItems} from "@welshman/store" +import type {ClientContext} from "./client.js" +import type {Router} from "./router.js" +import type {Profiles} from "./profiles.js" +import type {Topics, Topic} from "./topics.js" +import type {Relays} from "./relays.js" +import type {Handles} from "./handles.js" +import type {Wot} from "./wot.js" + +export type SearchOptions = { + getValue: (item: T) => V + fuseOptions?: IFuseOptions + onSearch?: (term: string) => void + sortFn?: (items: FuseResult) => any +} + +export type Search = { + options: T[] + getValue: (item: T) => V + getOption: (value: V) => T | undefined + searchOptions: (term: string) => T[] + searchValues: (term: string) => V[] +} + +export const createSearch = (options: T[], opts: SearchOptions): Search => { + const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true}) + const map = new Map(options.map(item => [opts.getValue(item), item])) + + const search = (term: string) => { + opts.onSearch?.(term) + + let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult) + + if (opts.sortFn) { + results = sortBy(opts.sortFn, results) + } + + return results.map(result => result.item) + } + + return { + options, + getValue: opts.getValue, + getOption: (value: V) => map.get(value), + searchOptions: (term: string) => search(term), + searchValues: (term: string) => search(term).map(opts.getValue), + } +} + +/** + * Reactive fuzzy searches over the client's profiles, topics, and relays. + * `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and + * fires a debounced NIP-50 network search through the client's loader. + */ +export class Searches { + profileSearch: Readable> + topicSearch: Readable> + relaySearch: Readable> + + constructor( + readonly ctx: ClientContext, + readonly router: Router, + readonly profiles: Profiles, + readonly topics: Topics, + readonly relays: Relays, + readonly handles: Handles, + readonly wot: Wot, + ) { + this.profileSearch = derived( + [throttled(800, this.profiles.all), throttled(800, this.handles)], + ([$profiles, $handlesByNip05]) => { + // Remove invalid nip05's from profiles + const options = $profiles.map(p => { + const isNip05Valid = + !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey + + return isNip05Valid ? p : {...p, nip05: ""} + }) + + return createSearch(options, { + onSearch: this.searchProfiles, + getValue: (profile: PublishedProfile) => profile.event.pubkey, + sortFn: ({score = 1, item}) => { + const wotScore = this.wot.getWotGraph().get(item.event.pubkey) || 0 + + return dec(score) * inc(wotScore / (this.wot.getMaxWot() || 1)) + }, + fuseOptions: { + keys: [ + "nip05", + {name: "name", weight: 0.8}, + {name: "display_name", weight: 0.5}, + {name: "about", weight: 0.3}, + ], + threshold: 0.3, + shouldSort: false, + }, + }) + }, + ) + + this.topicSearch = derived(this.topics.all, $topics => + createSearch($topics, { + getValue: (topic: Topic) => topic.name, + fuseOptions: {keys: ["name"]}, + }), + ) + + this.relaySearch = derived(deriveItems(this.relays), $relays => + createSearch($relays, { + getValue: (relay: RelayProfile) => relay.url, + fuseOptions: { + keys: ["url", "name", {name: "description", weight: 0.3}], + }, + }), + ) + } + + searchProfiles = debounce(500, (search: string) => { + if (search.length > 2) { + this.ctx.load({ + filters: [{kinds: [PROFILE], search}], + relays: this.router.Search().getUrls(), + }) + } + }) +} diff --git a/packages/client/src/searchRelayLists.ts b/packages/client/src/searchRelayLists.ts new file mode 100644 index 0000000..d4c4f15 --- /dev/null +++ b/packages/client/src/searchRelayLists.ts @@ -0,0 +1,27 @@ +import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {RepositoryCollection} from "./repositoryCollection.js" +import type {ClientContext} from "./client.js" +import type {RelayLists} from "./relayLists.js" + +/** + * NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the + * outbox model (the author's write relays), so it depends on the relay-list + * collection. + */ +export class SearchRelayLists extends RepositoryCollection> { + constructor( + ctx: ClientContext, + readonly relayLists: RelayLists, + ) { + super(ctx, { + filters: [{kinds: [SEARCH_RELAYS]}], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + getKey: searchRelayList => searchRelayList.event.pubkey, + }) + } + + fetch(pubkey: string, relayHints: string[] = []) { + return this.relayLists.makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints) + } +} diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts new file mode 100644 index 0000000..7902d26 --- /dev/null +++ b/packages/client/src/session.ts @@ -0,0 +1,248 @@ +import {Client as PomadeClient, PomadeSigner} from "@pomade/core" +import type {ClientOptions as PomadeClientOptions} from "@pomade/core" +import {writable} from "svelte/store" +import {randomId, append} from "@welshman/lib" +import {getPubkey} from "@welshman/util" +import { + WrappedSigner, + Nip46Broker, + Nip46Signer, + Nip07Signer, + Nip01Signer, + Nip55Signer, +} from "@welshman/signer" +import type {ISigner} from "@welshman/signer" +import {User} from "./user.js" +import type {UserOptions} from "./user.js" + +/** + * Session descriptors and the signer construction that turns them into a + * `User`. In the old global package these fed a multi-account registry (a single + * `sessions` map + `pubkey` pointer over one shared repository — the root of the + * merged-DM bug). In the per-client model each session becomes its own `User` + * (and thus its own `Client` with its own repository), so "multi-account" is + * just "multiple clients" and lives above this module. + */ + +export enum SessionMethod { + Nip01 = "nip01", + Nip07 = "nip07", + Nip46 = "nip46", + Nip55 = "nip55", + Pomade = "pomade", + Pubkey = "pubkey", + Anonymous = "anonymous", +} + +export type SessionNip01 = { + method: SessionMethod.Nip01 + pubkey: string + secret: string +} + +export type SessionNip07 = { + method: SessionMethod.Nip07 + pubkey: string +} + +export type SessionNip46 = { + method: SessionMethod.Nip46 + pubkey: string + secret: string + handler: { + pubkey: string + relays: string[] + } +} + +export type SessionNip55 = { + method: SessionMethod.Nip55 + pubkey: string + signer: string +} + +export type SessionPomade = { + method: SessionMethod.Pomade + pubkey: string + clientOptions: PomadeClientOptions + email: string +} + +export type SessionPubkey = { + method: SessionMethod.Pubkey + pubkey: string +} + +export type SessionAnonymous = { + method: SessionMethod.Anonymous +} + +export type SessionAnyMethod = + | SessionNip01 + | SessionNip07 + | SessionNip46 + | SessionNip55 + | SessionPomade + | SessionPubkey + | SessionAnonymous + +export type Session = SessionAnyMethod & Record + +// Session factories + +export const makeNip01Session = (secret: string): SessionNip01 => ({ + method: SessionMethod.Nip01, + secret, + pubkey: getPubkey(secret), +}) + +export const makeNip07Session = (pubkey: string): SessionNip07 => ({ + method: SessionMethod.Nip07, + pubkey, +}) + +export const makeNip46Session = ( + pubkey: string, + clientSecret: string, + signerPubkey: string, + relays: string[], +): SessionNip46 => ({ + method: SessionMethod.Nip46, + pubkey, + secret: clientSecret, + handler: {pubkey: signerPubkey, relays}, +}) + +export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({ + method: SessionMethod.Nip55, + pubkey, + signer, +}) + +export const makePomadeSession = ( + pubkey: string, + email: string, + clientOptions: PomadeClientOptions, +): SessionPomade => ({ + method: SessionMethod.Pomade, + pubkey, + clientOptions, + email, +}) + +export const makePubkeySession = (pubkey: string): SessionPubkey => ({ + method: SessionMethod.Pubkey, + pubkey, +}) + +// Type guards + +export const isNip01Session = (session?: Session): session is SessionNip01 => + session?.method === SessionMethod.Nip01 + +export const isNip07Session = (session?: Session): session is SessionNip07 => + session?.method === SessionMethod.Nip07 + +export const isNip46Session = (session?: Session): session is SessionNip46 => + session?.method === SessionMethod.Nip46 + +export const isNip55Session = (session?: Session): session is SessionNip55 => + session?.method === SessionMethod.Nip55 + +export const isPomadeSession = (session?: Session): session is SessionPomade => + session?.method === SessionMethod.Pomade + +export const isPubkeySession = (session?: Session): session is SessionPubkey => + session?.method === SessionMethod.Pubkey + +// Signer construction + +export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" + +export type SignerLogEntry = { + id: string + method: string + started_at: number + finished_at?: number + ok?: boolean +} + +export const signerLog = writable([]) + +export const wrapSigner = (signer: ISigner) => + new WrappedSigner(signer, async (method: string, thunk: () => Promise) => { + const id = randomId() + + signerLog.update(log => append({id, method, started_at: Date.now()}, log)) + + try { + const result = await thunk() + + signerLog.update(log => + log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)), + ) + + return result + } catch (error: any) { + signerLog.update(log => + log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)), + ) + + throw error + } + }) + +export const getSigner = (session?: Session): ISigner | undefined => { + if (isNip07Session(session)) return wrapSigner(new Nip07Signer()) + if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret)) + if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey)) + if (isPomadeSession(session)) return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions))) + if (isNip46Session(session)) { + const { + secret: clientSecret, + handler: {relays, pubkey: signerPubkey}, + } = session + const broker = new Nip46Broker({clientSecret, signerPubkey, relays}) + + return wrapSigner(new Nip46Signer(broker)) + } +} + +/** + * Build a `User` (pubkey + signer) from a session descriptor. Returns undefined + * for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the + * result to `new Client({user})` / `createApp({user})`. + */ +export const userFromSession = (session: Session, options: UserOptions = {}): User | undefined => { + const signer = getSigner(session) + + return signer && typeof session.pubkey === "string" + ? new User(session.pubkey, signer, options) + : undefined +} + +// Login helpers — each returns a User to build a client/app with + +export const loginWithNip01 = (secret: string, options?: UserOptions) => + userFromSession(makeNip01Session(secret), options) + +export const loginWithNip07 = (pubkey: string, options?: UserOptions) => + userFromSession(makeNip07Session(pubkey), options) + +export const loginWithNip46 = ( + pubkey: string, + clientSecret: string, + signerPubkey: string, + relays: string[], + options?: UserOptions, +) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options) + +export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) => + userFromSession(makeNip55Session(pubkey, signer), options) + +export const loginWithPomade = ( + pubkey: string, + email: string, + clientOptions: PomadeClientOptions, + options?: UserOptions, +) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options) diff --git a/packages/client/src/sync.ts b/packages/client/src/sync.ts new file mode 100644 index 0000000..bced0a2 --- /dev/null +++ b/packages/client/src/sync.ts @@ -0,0 +1,61 @@ +import {isSignedEvent} from "@welshman/util" +import type {Filter, SignedEvent} from "@welshman/util" +import type {ClientContext} from "./client.js" +import type {Relays} from "./relays.js" + +export type AppSyncOpts = { + relays: string[] + filters: Filter[] +} + +/** + * Negentropy-aware sync. Pulls/pushes events between the local repository and a + * set of relays, using NIP-77 reconciliation where the relay supports it and + * falling back to plain request/publish otherwise. Reads NIP-11 relay profiles + * from the injected `Relays` collection to detect negentropy support. + */ +export class Sync { + constructor( + readonly ctx: ClientContext, + readonly relays: Relays, + ) {} + + query = (filters: Filter[]) => + this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)}) + + hasNegentropy = (url: string) => { + const relay = this.relays.get(url) + + if (relay?.negentropy) return true + if (relay?.supported_nips?.includes?.("77")) return true + if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true + + return false + } + + pull = async ({relays, filters}: AppSyncOpts) => { + const events = this.query(filters).filter(isSignedEvent) + + await Promise.all( + relays.map(async relay => { + await (this.hasNegentropy(relay) + ? this.ctx.pull({filters, events, relays: [relay]}) + : this.ctx.request({filters, relays: [relay], autoClose: true})) + }), + ) + } + + push = async ({relays, filters}: AppSyncOpts) => { + const events = this.query(filters).filter(isSignedEvent) + + await Promise.all( + relays.map(async relay => { + await (this.hasNegentropy(relay) + ? this.ctx.push({filters, events, relays: [relay]}) + : Promise.all( + events.map((event: SignedEvent) => this.ctx.publish({event, relays: [relay]})), + )) + }), + ) + } +} diff --git a/packages/client/src/tags.ts b/packages/client/src/tags.ts new file mode 100644 index 0000000..596c917 --- /dev/null +++ b/packages/client/src/tags.ts @@ -0,0 +1,146 @@ +import {uniq, remove} from "@welshman/lib" +import { + getAddress, + isReplaceable, + getReplyTags, + getPubkeyTagValues, + isReplaceableKind, + isShareableRelayUrl, +} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import type {Router} from "./router.js" +import type {Profiles} from "./profiles.js" +import type {ClientContext} from "./client.js" + +/** + * Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router + * for relay hints, the profiles collection for display names, and the client's + * user to avoid self-tagging. + */ +export class Tags { + constructor( + readonly ctx: ClientContext, + readonly router: Router, + readonly profiles: Profiles, + ) {} + + tagZapSplit = (pubkey: string, split = 1) => [ + "zap", + pubkey, + this.router.FromPubkey(pubkey).getUrl() || "", + String(split), + ] + + tagPubkey = (pubkey: string) => [ + "p", + pubkey, + this.router.FromPubkey(pubkey).getUrl() || "", + this.profiles.display(pubkey), + ] + + tagEvent = (event: TrustedEvent, url = "", mark = "") => { + if (!url) { + url = this.router.Event(event).getUrl() || "" + } + + const tags = [["e", event.id, url, mark, event.pubkey]] + + if (isReplaceable(event)) { + tags.push(["a", getAddress(event), url, mark, event.pubkey]) + } + + return tags + } + + tagEventPubkeys = (event: TrustedEvent) => + uniq( + remove(this.ctx.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]), + ).map(pubkey => this.tagPubkey(pubkey)) + + tagEventForQuote = (event: TrustedEvent, relay?: string) => { + const hint = relay || this.router.Event(event).getUrl() || "" + + return ["q", event.id, hint, event.pubkey] + } + + tagEventForReply = (event: TrustedEvent, relay?: string) => { + const tags = this.tagEventPubkeys(event) + const {roots, replies} = getReplyTags(event.tags) + const parents = roots.length > 0 ? roots : replies + const mark = parents.length > 0 ? "reply" : "root" + const hint = relay || this.router.Event(event).getUrl() || "" + + // If the parent included roots use them, otherwise use replies as a fallback + for (const [k, id, originalHint = "", _, pubkey = ""] of parents) { + const hint = isShareableRelayUrl(originalHint) + ? originalHint + : this.router.EventRoots(event).getUrl() + + tags.push([k, id, hint || "", "root", pubkey]) + } + + // e-tag the event + tags.push(["e", event.id, hint, mark, event.pubkey]) + + // a-tag the event + if (isReplaceable(event)) { + tags.push(["a", getAddress(event), hint, mark, event.pubkey]) + } + + return tags + } + + tagEventForComment = (event: TrustedEvent, relay?: string) => { + const pubkeyHint = this.router.FromPubkey(event.pubkey).getUrl() || "" + const eventHint = relay || this.router.Event(event).getUrl() || "" + const address = getAddress(event) + const seenRoots = new Set() + const tags: string[][] = [] + + for (const [t, ...tag] of event.tags) { + if (["K", "E", "A", "I", "P"].includes(t)) { + tags.push([t, ...tag]) + seenRoots.add(t) + } + } + + if (seenRoots.size === 0) { + tags.push(["K", String(event.kind)]) + tags.push(["P", event.pubkey, pubkeyHint]) + tags.push(["E", event.id, eventHint, event.pubkey]) + + if (isReplaceableKind(event.kind)) { + tags.push(["A", address, eventHint, event.pubkey]) + } + } + + tags.push(["k", String(event.kind)]) + tags.push(["p", event.pubkey, pubkeyHint]) + tags.push(["e", event.id, eventHint, event.pubkey]) + + if (isReplaceableKind(event.kind)) { + tags.push(["a", address, eventHint, event.pubkey]) + } + + return tags + } + + tagEventForReaction = (event: TrustedEvent, relay?: string) => { + const hint = relay || this.router.Event(event).getUrl() || "" + const tags: string[][] = [] + + // Mention the event's author + if (event.pubkey !== this.ctx.user?.pubkey) { + tags.push(this.tagPubkey(event.pubkey)) + } + + tags.push(["k", String(event.kind)]) + tags.push(["e", event.id, hint]) + + if (isReplaceable(event)) { + tags.push(["a", getAddress(event), hint]) + } + + return tags + } +} diff --git a/packages/client/src/thunk.ts b/packages/client/src/thunk.ts index 433bf4c..b72824b 100644 --- a/packages/client/src/thunk.ts +++ b/packages/client/src/thunk.ts @@ -13,14 +13,14 @@ import { } from "@welshman/util" import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net" import {Nip01Signer, Nip59} from "@welshman/signer" -import type {Client} from './client.js' +import type {ClientContext} from './client.js' import type {User} from './user.js' export type ThunkOptions = Override< PublishOptions, { user: User - client: Client + client: ClientContext event: EventTemplate recipient?: string delay?: number diff --git a/packages/client/src/topics.ts b/packages/client/src/topics.ts new file mode 100644 index 0000000..9b47628 --- /dev/null +++ b/packages/client/src/topics.ts @@ -0,0 +1,59 @@ +import {readable} from "svelte/store" +import type {Readable} from "svelte/store" +import {on} from "@welshman/lib" +import {getTopicTagValues} from "@welshman/util" +import {deriveItems} from "@welshman/store" +import type {ClientContext} from "./client.js" + +export type Topic = { + name: string + count: number +} + +/** + * Hashtag topics with occurrence counts, derived live from the client's + * repository tag index. + */ +export class Topics { + byName: Readable> + all: Readable + + constructor(readonly ctx: ClientContext) { + const topicsByName = new Map() + + const addTopic = (name: string) => { + const topic = topicsByName.get(name) + + if (topic) { + topic.count++ + } else { + topicsByName.set(name, {name, count: 1}) + } + } + + for (const tagString of ctx.repository.eventsByTag.keys()) { + if (tagString.startsWith("t:")) { + addTopic(tagString.slice(2).toLowerCase()) + } + } + + this.byName = readable(topicsByName, set => + on(ctx.repository, "update", ({added}: {added: {tags: string[][]}[]}) => { + let dirty = false + + for (const event of added) { + for (const name of getTopicTagValues(event.tags)) { + addTopic(name) + dirty = true + } + } + + if (dirty) { + set(topicsByName) + } + }), + ) + + this.all = deriveItems(this.byName) + } +} diff --git a/packages/client/src/user.ts b/packages/client/src/user.ts index 5e46dad..d5a96a1 100644 --- a/packages/client/src/user.ts +++ b/packages/client/src/user.ts @@ -1,19 +1,25 @@ -import {Maybe} from '@welshman/lib' -import {Repository, AdapterFactory, NetContext, WrapManager, DiffOptions, PullOptions, PushOptions, RequestOptions, PublishOptions, LoaderOptions, Tracker, Pool, push, pull, diff, publish, request, makeLoader} from '@welshman/net' -import {ISigner} from '@welshman/signer' +import {makeSocketPolicyAuth} from "@welshman/net" +import type {Socket} from "@welshman/net" +import type {StampedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" export type UserOptions = { shouldAuth?: (socket: Socket) => boolean } +/** + * A single identity: a pubkey plus the signer that proves it. A `Client` is + * centered on (at most) one `User`, since the data a user can access depends + * entirely on who they are. + */ export class User { constructor( readonly pubkey: string, readonly signer: ISigner, - readonly options: UserOptions + readonly options: UserOptions = {}, ) {} - static async fromSigner(signer: ISigner, options: UserOptions) { + static async fromSigner(signer: ISigner, options: UserOptions = {}) { const pubkey = await signer.getPubkey() return new User(pubkey, signer, options) @@ -24,4 +30,8 @@ export class User { sign: this.signer.sign, shouldAuth: this.options.shouldAuth, }) + + sign = (event: StampedEvent) => this.signer.sign(event) + + nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload) } diff --git a/packages/client/src/wot.ts b/packages/client/src/wot.ts new file mode 100644 index 0000000..32e3040 --- /dev/null +++ b/packages/client/src/wot.ts @@ -0,0 +1,129 @@ +import {derived, writable} from "svelte/store" +import type {Readable, Writable} from "svelte/store" +import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib" +import {getListTags, getPubkeyTagValues} from "@welshman/util" +import {throttled, getter} from "@welshman/store" +import type {ClientContext} from "./client.js" +import type {FollowLists} from "./follows.js" +import type {MuteLists} from "./mutes.js" + +/** + * Web-of-trust scoring derived from follow and mute lists. The trust graph is + * built from the perspective of the client's user (or, with no user, the union + * of every known follow list) and updated reactively as lists change. + */ +export class Wot { + followersByPubkey: Readable>> + mutersByPubkey: Readable>> + wotGraph: Writable> + maxWot: Readable + + private getFollowersByPubkeyStore: () => Map> + private getMutersByPubkeyStore: () => Map> + private getWotGraphStore: () => Map + private getMaxWotStore: () => number | undefined + + constructor( + readonly ctx: ClientContext, + readonly followLists: FollowLists, + readonly muteLists: MuteLists, + ) { + this.followersByPubkey = derived(throttled(1000, this.followLists.all), lists => { + const $followersByPubkey = new Map>() + + for (const list of lists) { + for (const pubkey of getPubkeyTagValues(getListTags(list))) { + addToMapKey($followersByPubkey, pubkey, list.event.pubkey) + } + } + + return $followersByPubkey + }) + + this.mutersByPubkey = derived(throttled(1000, this.muteLists.all), lists => { + const $mutersByPubkey = new Map>() + + for (const list of lists) { + for (const pubkey of getPubkeyTagValues(getListTags(list))) { + addToMapKey($mutersByPubkey, pubkey, list.event.pubkey) + } + } + + return $mutersByPubkey + }) + + this.wotGraph = writable(new Map()) + + this.maxWot = derived(this.wotGraph, $g => max(Array.from($g.values()))) + + this.getFollowersByPubkeyStore = getter(this.followersByPubkey) + this.getMutersByPubkeyStore = getter(this.mutersByPubkey) + this.getWotGraphStore = getter(this.wotGraph) + this.getMaxWotStore = getter(this.maxWot) + + this.followLists.subscribe(this.buildGraph) + this.muteLists.subscribe(this.buildGraph) + } + + getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(this.followLists.get(pubkey))) + + getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(this.muteLists.get(pubkey))) + + getNetwork = (pubkey: string) => { + const pubkeys = new Set(this.getFollows(pubkey)) + const network = new Set() + + for (const follow of pubkeys) { + for (const tpk of this.getFollows(follow)) { + if (!pubkeys.has(tpk)) { + network.add(tpk) + } + } + } + + return Array.from(network) + } + + getFollowersByPubkey = () => this.getFollowersByPubkeyStore() + + getMutersByPubkey = () => this.getMutersByPubkeyStore() + + getFollowers = (pubkey: string) => Array.from(this.getFollowersByPubkey().get(pubkey) || []) + + getMuters = (pubkey: string) => Array.from(this.getMutersByPubkey().get(pubkey) || []) + + getFollowsWhoFollow = (pubkey: string, target: string) => + this.getFollows(pubkey).filter(other => this.getFollows(other).includes(target)) + + getFollowsWhoMute = (pubkey: string, target: string) => + this.getFollows(pubkey).filter(other => this.getMutes(other).includes(target)) + + getWotGraph = () => this.getWotGraphStore() + + getMaxWot = () => this.getMaxWotStore() + + buildGraph = throttle(1000, () => { + const $pubkey = this.ctx.user?.pubkey + const $graph = new Map() + const $follows = $pubkey ? this.getFollows($pubkey) : Array.from(this.followLists.keys()) + + for (const follow of $follows) { + for (const pubkey of this.getFollows(follow)) { + $graph.set(pubkey, inc($graph.get(pubkey))) + } + + for (const pubkey of this.getMutes(follow)) { + $graph.set(pubkey, dec($graph.get(pubkey))) + } + } + + this.wotGraph.set($graph) + }) + + getWotScore = (pubkey: string, target: string) => { + const follows = pubkey ? this.getFollowsWhoFollow(pubkey, target) : this.getFollowers(target) + const mutes = pubkey ? this.getFollowsWhoMute(pubkey, target) : this.getMuters(target) + + return follows.length - mutes.length + } +} diff --git a/packages/client/src/zappers.ts b/packages/client/src/zappers.ts new file mode 100644 index 0000000..991835e --- /dev/null +++ b/packages/client/src/zappers.ts @@ -0,0 +1,130 @@ +import {writable} from "svelte/store" +import { + removeUndefined, + fetchJson, + bech32ToHex, + hexToBech32, + tryCatch, + batcher, + postJson, +} from "@welshman/lib" +import {getTagValues, zapFromEvent} from "@welshman/util" +import type {Zapper, Zap, TrustedEvent} from "@welshman/util" +import {deriveDeduplicated} from "@welshman/store" +import {LoadableData} from "./clientData.js" +import type {ClientContext} from "./client.js" +import type {Profiles} from "./profiles.js" + +/** + * Lightning zapper info, keyed by lnurl. A "local" loadable collection: items + * aren't nostr events, they're fetched over HTTP (either directly from each + * lnurl, or via a dufflepud proxy to protect user privacy). Depends on the + * profiles collection to resolve a pubkey's lnurl. + */ +export class Zappers extends LoadableData { + constructor( + ctx: ClientContext, + readonly profiles: Profiles, + readonly options: {dufflepudUrl?: string} = {}, + ) { + super(ctx) + } + + fetch = batcher(800, async (lnurls: string[]) => { + const result = new Map() + const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1")) + + const addZapper = (lnurl: string, info: any) => { + if (info) { + try { + result.set(lnurl, {...info, lnurl}) + } catch (_e) { + // pass + } + } + } + + // Use dufflepud if it's set up to protect user privacy, otherwise fetch directly + if (this.options.dufflepudUrl) { + const hexUrls = valid.map(bech32ToHex) + const res: any = await tryCatch( + async () => + await postJson(`${this.options.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}), + ) + + for (const {lnurl, info} of res?.data || []) { + addZapper(hexToBech32("lnurl", lnurl), info) + } + } else { + await Promise.all( + valid.map(async lnurl => { + addZapper(lnurl, await tryCatch(async () => await fetchJson(bech32ToHex(lnurl)))) + }), + ) + } + + for (const [lnurl, zapper] of result) { + this.set(lnurl, zapper) + } + + return lnurls.map(lnurl => result.get(lnurl)) + }) + + loadForPubkey = async (pubkey: string, relays: string[] = []) => { + const $profile = await this.profiles.load(pubkey, relays) + + return $profile?.lnurl ? this.load($profile.lnurl) : undefined + } + + deriveForPubkey = (pubkey: string, relays: string[] = []) => { + this.loadForPubkey(pubkey, relays) + + return deriveDeduplicated( + [this.index, this.profiles.derive(pubkey, relays)], + ([$zappersByLnurl, $profile]) => + $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined, + ) + } + + getLnUrlsForEvent = async (event: TrustedEvent) => { + const pubkeys = getTagValues("zap", event.tags) + + if (pubkeys.length > 0) { + const profiles = await Promise.all(pubkeys.map(pubkey => this.profiles.load(pubkey))) + const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl)) + + if (lnurls.length > 0) { + return lnurls + } + } + + const profile = await this.profiles.load(event.pubkey) + + return removeUndefined([profile?.lnurl]) + } + + getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => { + const lnurls = await this.getLnUrlsForEvent(parent) + + return lnurls.length > 0 ? this.load(lnurls[0]) : undefined + } + + getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => { + const zapper = await this.getZapperForZap(zap, parent) + + return zapper ? zapFromEvent(zap, zapper) : undefined + } + + getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) => + removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent)))) + + deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => { + const store = writable([]) + + this.getValidZaps(zaps, parent).then(validZaps => { + store.set(validZaps) + }) + + return store + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f39657f..c1504fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,52 @@ importers: specifier: ~5.8.0 version: 5.8.2 + packages/client: + dependencies: + fuse.js: + specifier: ^7.0.0 + version: 7.1.0 + throttle-debounce: + specifier: ^5.0.2 + version: 5.0.2 + devDependencies: + '@pomade/core': + specifier: ^0.2.1 + version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.8.2))(@noble/hashes@2.0.1)(@welshman/lib@packages+lib)(@welshman/net@packages+net)(@welshman/signer@packages+signer)(@welshman/util@packages+util)(nostr-tools@2.19.4(typescript@5.8.2)) + '@types/throttle-debounce': + specifier: ^5.0.2 + version: 5.0.2 + '@welshman/feeds': + specifier: workspace:* + version: link:../feeds + '@welshman/lib': + specifier: workspace:* + version: link:../lib + '@welshman/net': + specifier: workspace:* + version: link:../net + '@welshman/router': + specifier: workspace:* + version: link:../router + '@welshman/signer': + specifier: workspace:* + version: link:../signer + '@welshman/store': + specifier: workspace:* + version: link:../store + '@welshman/util': + specifier: workspace:* + version: link:../util + rimraf: + specifier: ~6.0.0 + version: 6.0.1 + svelte: + specifier: ^5.39.12 + version: 5.46.3 + typescript: + specifier: ~5.8.0 + version: 5.8.2 + packages/content: dependencies: '@braintree/sanitize-url': diff --git a/todo.md b/todo.md deleted file mode 100644 index 375ee3f..0000000 --- a/todo.md +++ /dev/null @@ -1,5 +0,0 @@ -- [ ] domain package -- [ ] client package -- [ ] storage package - - [ ] provide an interface which client can use to save/load - - [ ] don't implement anything unless in separate packages